新增上传资产模型与迁移,支持 IMAGE、VIDEO、FILE 三类资产管理
新增 B 端上传接口与列表接口,统一文件上传和分页查询能力 上传能力支持医院级数据隔离:系统管理员需显式指定医院,院内角色按登录医院自动隔离 图片上传自动压缩并转为 webp,视频上传自动转码并压缩为 mp4,普通文件按原始类型存储 增加上传目录与公开访问能力,统一输出可直接预览的访问地址 前端新增影像库页面,支持按类型筛选、关键字检索、分页浏览、在线预览与原文件访问 前端新增通用上传组件,支持在页面内复用并返回上传结果 管理后台新增影像库菜单与路由,并补充页面级角色权限控制 患者手术相关表单接入上传复用能力,支持术前资料与设备标签上传回填 新增上传模块 e2e 用例,覆盖成功路径、权限矩阵与关键失败场景 补充上传模块文档与安装依赖说明,完善工程内使用说明
@ -13,20 +13,20 @@
|
|||||||
|
|
||||||
核心字段:
|
核心字段:
|
||||||
|
|
||||||
- `snCode`:设备唯一标识
|
|
||||||
- `patientId`:归属患者
|
- `patientId`:归属患者
|
||||||
- `surgeryId`:归属手术,可为空
|
- `surgeryId`:归属手术,可为空
|
||||||
- `implantCatalogId`:型号字典 ID,可为空
|
- `implantCatalogId`:型号字典 ID,可为空
|
||||||
- `implantModel` / `implantManufacturer` / `implantName`:历史快照
|
- `implantModel` / `implantManufacturer` / `implantName`:历史快照
|
||||||
- `isPressureAdjustable`:是否可调压
|
- `isPressureAdjustable`:是否可调压
|
||||||
- `isAbandoned`:是否弃用
|
- `isAbandoned`:是否弃用
|
||||||
- `currentPressure`:当前压力
|
- `currentPressure`:当前压力挡位标签
|
||||||
- `status`:设备状态
|
- `status`:设备状态
|
||||||
|
|
||||||
补充:
|
补充:
|
||||||
|
|
||||||
- `currentPressure` 不允许在创建/编辑设备实例时手工指定。
|
- `currentPressure` 不允许在创建/编辑设备实例时手工指定。
|
||||||
- 新植入设备默认以 `initialPressure`(或系统默认值)作为当前压力起点,后续只允许在调压任务完成时更新。
|
- 新植入设备默认以 `initialPressure`(或系统默认值 `0`)作为当前压力起点,后续只允许在调压任务完成时更新。
|
||||||
|
- 发布调压任务时不会立刻修改 `currentPressure`,只有任务完成后才会把目标挡位回写到设备。
|
||||||
|
|
||||||
## 3. 植入物目录
|
## 3. 植入物目录
|
||||||
|
|
||||||
@ -35,7 +35,7 @@
|
|||||||
- `modelCode`:型号编码,唯一
|
- `modelCode`:型号编码,唯一
|
||||||
- `manufacturer`:厂商
|
- `manufacturer`:厂商
|
||||||
- `name`:名称
|
- `name`:名称
|
||||||
- `pressureLevels`:可调压器械的挡位列表
|
- `pressureLevels`:可调压器械的挡位字符串标签列表
|
||||||
- `isPressureAdjustable`:是否可调压
|
- `isPressureAdjustable`:是否可调压
|
||||||
- `notes`:目录备注
|
- `notes`:目录备注
|
||||||
|
|
||||||
@ -45,6 +45,11 @@
|
|||||||
- 仅 `SYSTEM_ADMIN` 可做目录 CRUD
|
- 仅 `SYSTEM_ADMIN` 可做目录 CRUD
|
||||||
- 目录是全局共享的,不按医院隔离
|
- 目录是全局共享的,不按医院隔离
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 挡位列表按字符串标签保存,例如 `["0.5", "1", "1.5"]` 或 `["10", "20", "30"]`。
|
||||||
|
- 保存前会自动标准化并去重排序,例如 `["01.0", "1.50", "1"]` 最终会整理为 `["1", "1.5"]`。
|
||||||
|
|
||||||
## 4. 接口
|
## 4. 接口
|
||||||
|
|
||||||
设备实例:
|
设备实例:
|
||||||
@ -65,7 +70,6 @@
|
|||||||
## 5. 约束
|
## 5. 约束
|
||||||
|
|
||||||
- 设备必须绑定到一个患者。
|
- 设备必须绑定到一个患者。
|
||||||
- 设备 SN 在全库唯一,服务端会统一转成大写后再校验。
|
|
||||||
- 删除已被任务明细引用的设备会返回 `409`。
|
- 删除已被任务明细引用的设备会返回 `409`。
|
||||||
- 删除已被患者手术引用的植入物目录会返回 `409`。
|
- 删除已被患者手术引用的植入物目录会返回 `409`。
|
||||||
- 可调压植入物若配置了 `pressureLevels`,患者手术录入和任务调压时的压力值必须命中该挡位列表。
|
- 可调压植入物若配置了 `pressureLevels`,患者手术录入和任务调压时的压力值必须命中该挡位列表。
|
||||||
|
|||||||
@ -4,17 +4,17 @@
|
|||||||
|
|
||||||
- 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。
|
- 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。
|
||||||
- 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。
|
- 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。
|
||||||
- 测试前固定执行数据库重置与 seed,确保结果可重复。
|
- 测试前固定执行数据库重置,并通过真实接口全流程建数,确保结果可重复。
|
||||||
|
|
||||||
## 2. 风险提示
|
## 2. 风险提示
|
||||||
|
|
||||||
`pnpm test:e2e` 会执行:
|
`pnpm test:e2e` 会执行:
|
||||||
|
|
||||||
1. `prisma migrate reset --force`
|
1. `prisma migrate reset --force`
|
||||||
2. `node prisma/seed.mjs`
|
2. 启动 Jest 后,由测试用例通过真实 HTTP 接口完成基础夹具创建
|
||||||
|
|
||||||
这会清空 `.env` 中 `DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
|
这会清空 `.env` 中 `DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
|
||||||
另外,seed 账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
|
另外,接口引导创建的测试账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
|
||||||
|
|
||||||
## 3. 运行命令
|
## 3. 运行命令
|
||||||
|
|
||||||
@ -22,7 +22,7 @@
|
|||||||
pnpm test:e2e
|
pnpm test:e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
仅重置数据库并注入 seed:
|
仅重置数据库并重新生成 Prisma Client:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm test:e2e:prepare
|
pnpm test:e2e:prepare
|
||||||
@ -34,7 +34,7 @@ pnpm test:e2e:prepare
|
|||||||
pnpm test:e2e:watch
|
pnpm test:e2e:watch
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. 种子账号(默认密码:`Seed@1234`)
|
## 4. 接口引导夹具(默认密码:`Seed@1234`)
|
||||||
|
|
||||||
- 系统管理员:`13800001000`
|
- 系统管理员:`13800001000`
|
||||||
- 院管(医院 A):`13800001001`
|
- 院管(医院 A):`13800001001`
|
||||||
@ -42,20 +42,37 @@ pnpm test:e2e:watch
|
|||||||
- 组长(医院 A):`13800001003`
|
- 组长(医院 A):`13800001003`
|
||||||
- 医生(医院 A):`13800001004`
|
- 医生(医院 A):`13800001004`
|
||||||
- 工程师(医院 A):`13800001005`
|
- 工程师(医院 A):`13800001005`
|
||||||
|
- 院管(医院 B):`13800001011`
|
||||||
|
- 工程师(医院 B):`13800001015`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 这些账号不再由 `prisma/seed.mjs` 直写生成。
|
||||||
|
- 每次执行 E2E 时,会先创建系统管理员,再通过后台接口依次创建医院、院管、医生、工程师、目录、患者、手术和调压任务。
|
||||||
|
- 因为夹具是通过真实业务接口生成的,所以权限、作用域、删除保护和调压链路都能在同一套测试里被覆盖。
|
||||||
|
|
||||||
## 5. 用例结构
|
## 5. 用例结构
|
||||||
|
|
||||||
- `test/e2e/specs/auth.e2e-spec.ts`
|
- `test/e2e/specs/auth.e2e-spec.ts`
|
||||||
- `test/e2e/specs/users.e2e-spec.ts`
|
- `test/e2e/specs/users.e2e-spec.ts`
|
||||||
- `test/e2e/specs/organization.e2e-spec.ts`
|
- `test/e2e/specs/organization.e2e-spec.ts`
|
||||||
|
- `test/e2e/specs/dictionaries.e2e-spec.ts`
|
||||||
|
- `test/e2e/specs/devices.e2e-spec.ts`
|
||||||
- `test/e2e/specs/tasks.e2e-spec.ts`
|
- `test/e2e/specs/tasks.e2e-spec.ts`
|
||||||
- `test/e2e/specs/patients.e2e-spec.ts`
|
- `test/e2e/specs/patients.e2e-spec.ts`
|
||||||
|
- `test/e2e/specs/auth-token-revocation.e2e-spec.ts`
|
||||||
|
|
||||||
## 6. 覆盖策略
|
## 6. 覆盖策略
|
||||||
|
|
||||||
- 受保护接口(27 个):每个接口覆盖 6 角色访问结果 + 未登录 401。
|
- 受保护接口(27 个):每个接口覆盖 6 角色访问结果 + 未登录 401。
|
||||||
- 非受保护接口(3 个):每个接口至少 1 个成功 + 1 个失败。
|
- 非受保护接口(3 个):每个接口至少 1 个成功 + 1 个失败。
|
||||||
- 关键行为额外覆盖:
|
- 关键行为额外覆盖:
|
||||||
|
- 从创建系统管理员开始的完整接口建数链路
|
||||||
- 任务状态机冲突(409)
|
- 任务状态机冲突(409)
|
||||||
|
- 调压任务发布后不改当前压力,完成任务后才回写设备当前压力
|
||||||
|
- 主刀医生自动跟随患者归属医生,且历史手术保留快照
|
||||||
- 患者 B 端角色可见性
|
- 患者 B 端角色可见性
|
||||||
|
- 患者创建人返回与展示
|
||||||
|
- 跨院工程师隔离
|
||||||
- 组织域院管作用域限制与删除冲突
|
- 组织域院管作用域限制与删除冲突
|
||||||
|
- 目录、设备、组织、用户的删除保护
|
||||||
|
|||||||
@ -5,10 +5,11 @@
|
|||||||
- 登录页:`/auth/login`,支持可选 `hospitalId`。
|
- 登录页:`/auth/login`,支持可选 `hospitalId`。
|
||||||
- 首页看板:按角色拉取组织与患者统计。
|
- 首页看板:按角色拉取组织与患者统计。
|
||||||
- 设备页:新增管理员专用设备 CRUD,复用真实设备接口。
|
- 设备页:新增管理员专用设备 CRUD,复用真实设备接口。
|
||||||
- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。
|
- 任务页:改为只读调压记录页,接入真实任务列表接口。
|
||||||
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
|
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
|
||||||
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`),
|
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`),
|
||||||
后端直接保存身份证号原文,不再做哈希转换。
|
后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。
|
||||||
|
- 新增影像库页:接入真实上传接口,支持图片/视频/文件上传与分页查看。
|
||||||
|
|
||||||
## 2. 接口契约对齐点
|
## 2. 接口契约对齐点
|
||||||
|
|
||||||
@ -16,23 +17,27 @@
|
|||||||
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
|
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
|
||||||
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
|
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
|
||||||
- `GET /b/devices` 已支持服务端分页与筛选,前端直接透传 `page/pageSize`。
|
- `GET /b/devices` 已支持服务端分页与筛选,前端直接透传 `page/pageSize`。
|
||||||
|
- `GET /b/tasks` 返回 `{ list, total, page, pageSize }`,供任务页只读展示调压记录。
|
||||||
|
- `POST /b/uploads` 使用 `multipart/form-data` 上传文件,返回上传资产元数据。
|
||||||
|
- `GET /b/uploads` 返回 `{ list, total, page, pageSize }`,仅供系统管理员/医院管理员影像库分页展示。
|
||||||
|
- `GET /b/tasks/engineers` 返回可选接收工程师列表,患者页发布调压任务前需要先拉取。
|
||||||
- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCard`。
|
- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCard`。
|
||||||
- 患者表单中的 `idCard` 字段直接传身份证号;
|
- 患者表单中的 `idCard` 字段直接传身份证号;
|
||||||
服务端只会做去空格与 `x/X` 标准化,不会转哈希。
|
服务端只会做去空格与 `x/X` 标准化,不会转哈希。
|
||||||
- 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。
|
- 患者手术、调压任务、设备目录中的压力值全部按字符串挡位标签传输,例如 `0.5`、`1`、`1.5`、`10`。
|
||||||
|
|
||||||
## 3. 角色权限提示
|
## 3. 角色权限提示
|
||||||
|
|
||||||
- 任务接口权限:
|
- 任务接口权限:
|
||||||
- `DOCTOR/DIRECTOR/LEADER`:发布、取消(仅可取消自己创建的任务)
|
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时必须指定接收工程师;可取消自己创建的任务
|
||||||
- `ENGINEER`:接收、完成
|
- `ENGINEER`:仅可完成分配给自己的任务
|
||||||
- 患者列表权限:
|
- 患者列表权限:
|
||||||
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
||||||
- 用户管理接口:
|
- 用户管理接口:
|
||||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR` 可访问列表与创建
|
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可创建、编辑、删除
|
||||||
- `DIRECTOR` 页面语义调整为“医生管理”,仅管理本科室医生
|
- `DIRECTOR` 可只读查看本科室下级医生/组长
|
||||||
- 工程师绑定医院仅 `SYSTEM_ADMIN`
|
- 工程师绑定医院仅 `SYSTEM_ADMIN`
|
||||||
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`DIRECTOR` 可删除本科室无关联医生
|
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`HOSPITAL_ADMIN` 可删除本院无关联、且非管理员用户
|
||||||
|
|
||||||
## 3.1 结构图页面交互调整
|
## 3.1 结构图页面交互调整
|
||||||
|
|
||||||
@ -46,27 +51,29 @@
|
|||||||
`SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问
|
`SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问
|
||||||
- `organization/departments`:
|
- `organization/departments`:
|
||||||
仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
||||||
- `users`:`SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR` 可访问
|
- `users`:`SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可管理;`DIRECTOR` 可只读查看
|
||||||
- `devices`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
- `devices`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
||||||
|
- `uploads`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
||||||
- `organization/hospitals`
|
- `organization/hospitals`
|
||||||
- 仅 `SYSTEM_ADMIN` 可访问
|
- 仅 `SYSTEM_ADMIN` 可访问
|
||||||
- `tasks`
|
- `tasks`
|
||||||
- 仅 `DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问
|
- `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问
|
||||||
- `patients`
|
- `patients`
|
||||||
- `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问
|
- `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问
|
||||||
|
|
||||||
|
患者页负责发起调压任务并指定接收人,任务页仅查看调压记录,不再提供发布或接收入口。
|
||||||
|
|
||||||
|
患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。
|
||||||
|
|
||||||
前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。
|
前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。
|
||||||
|
|
||||||
## 3.3 主任/组长组织管理范围
|
## 3.3 主任/组长组织管理范围
|
||||||
|
|
||||||
- `DIRECTOR`
|
- `DIRECTOR`
|
||||||
- 可查看组织架构、小组列表(限定本科室范围)
|
- 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
|
||||||
- 可创建/编辑/删除本科室下小组
|
|
||||||
- 可进入“医生管理”页,创建/维护本科室医生
|
|
||||||
- `LEADER`
|
- `LEADER`
|
||||||
- 可查看组织架构、小组列表(限定本科室/本小组范围)
|
- 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
|
||||||
- 可编辑本小组名称
|
- 主任/组长不再显示“科室管理”“小组管理”“用户管理”页面。
|
||||||
- 主任/组长不再显示独立“科室管理”页面。
|
|
||||||
- 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。
|
- 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。
|
||||||
|
|
||||||
## 4. 本地运行
|
## 4. 本地运行
|
||||||
|
|||||||
@ -29,7 +29,8 @@
|
|||||||
|
|
||||||
- `surgeryDate`:手术日期
|
- `surgeryDate`:手术日期
|
||||||
- `surgeryName`:手术名称
|
- `surgeryName`:手术名称
|
||||||
- `surgeonName`:主刀医生
|
- `surgeonId`:主刀医生账号 ID(自动等于患者归属医生)
|
||||||
|
- `surgeonName`:主刀医生姓名快照
|
||||||
- `preOpPressure`:术前测压,可为空
|
- `preOpPressure`:术前测压,可为空
|
||||||
- `primaryDisease`:原发病
|
- `primaryDisease`:原发病
|
||||||
- `hydrocephalusTypes`:脑积水类型,多选
|
- `hydrocephalusTypes`:脑积水类型,多选
|
||||||
@ -43,6 +44,12 @@
|
|||||||
- `activeDeviceCount`:本次手术仍在用设备数
|
- `activeDeviceCount`:本次手术仍在用设备数
|
||||||
- `abandonedDeviceCount`:本次手术已弃用设备数
|
- `abandonedDeviceCount`:本次手术已弃用设备数
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 新增/修改手术时,前端不再单独提交主刀医生。
|
||||||
|
- 后端会直接使用患者当前的 `doctorId` 作为 `surgeonId`,并保存当时的 `surgeonName` 快照。
|
||||||
|
- 如果后续患者归属医生发生变化,只会影响后续新建手术;历史手术仍保留创建当时的主刀医生快照。
|
||||||
|
|
||||||
## 4. 植入设备
|
## 4. 植入设备
|
||||||
|
|
||||||
设备表仍沿用 `Device`,但语义改为“患者手术下植入的设备实例”。
|
设备表仍沿用 `Device`,但语义改为“患者手术下植入的设备实例”。
|
||||||
@ -57,7 +64,7 @@
|
|||||||
- `proximalPunctureAreas`:近端穿刺区域,最多 2 个
|
- `proximalPunctureAreas`:近端穿刺区域,最多 2 个
|
||||||
- `valvePlacementSites`:阀门植入部位,最多 2 个
|
- `valvePlacementSites`:阀门植入部位,最多 2 个
|
||||||
- `distalShuntDirection`:远端分流方向
|
- `distalShuntDirection`:远端分流方向
|
||||||
- `initialPressure`:初始压力,可为空
|
- `initialPressure`:初始压力挡位标签,可为空
|
||||||
- `implantNotes`:植入物备注,可为空
|
- `implantNotes`:植入物备注,可为空
|
||||||
- `labelImageUrl`:植入物标签图片地址,可为空
|
- `labelImageUrl`:植入物标签图片地址,可为空
|
||||||
|
|
||||||
@ -68,8 +75,10 @@
|
|||||||
- 旧设备弃用后,`TaskItem` 历史不会删除。
|
- 旧设备弃用后,`TaskItem` 历史不会删除。
|
||||||
- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。
|
- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。
|
||||||
- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。
|
- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。
|
||||||
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的值。
|
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的字符串标签值,例如 `0.5 / 1 / 1.5 / 10 / 20 / 30`。
|
||||||
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`,后续仅能由调压任务完成后更新。
|
- 挡位标签保存前会自动标准化,例如 `01.0 -> 1`、`1.50 -> 1.5`。
|
||||||
|
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`;发布调压任务时不会立刻改当前压力,只有工程师完成任务后才会更新。
|
||||||
|
- 术前资料与植入物标签现在支持直接上传;上传成功后,患者详情会按图片/视频做预览,不再只是裸链接。
|
||||||
|
|
||||||
## 5. B 端可见性
|
## 5. B 端可见性
|
||||||
|
|
||||||
@ -93,6 +102,7 @@
|
|||||||
|
|
||||||
- `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。
|
- `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。
|
||||||
- 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`。
|
- 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`。
|
||||||
|
- 患者建档时会自动记录 `creator`,列表和详情都会返回创建人信息。
|
||||||
|
|
||||||
## 7. C 端生命周期聚合
|
## 7. C 端生命周期聚合
|
||||||
|
|
||||||
@ -111,6 +121,10 @@
|
|||||||
- `SURGERY`
|
- `SURGERY`
|
||||||
- `TASK_PRESSURE_ADJUSTMENT`
|
- `TASK_PRESSURE_ADJUSTMENT`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 生命周期中的 `initialPressure / currentPressure / oldPressure / targetPressure` 均返回字符串挡位标签。
|
||||||
|
|
||||||
## 8. 响应结构
|
## 8. 响应结构
|
||||||
|
|
||||||
全部接口统一返回:
|
全部接口统一返回:
|
||||||
|
|||||||
@ -7,34 +7,47 @@
|
|||||||
|
|
||||||
## 2. 状态机
|
## 2. 状态机
|
||||||
|
|
||||||
- `PENDING -> ACCEPTED -> COMPLETED`
|
- 当前发布流程:`ACCEPTED -> COMPLETED`
|
||||||
- `PENDING/ACCEPTED -> CANCELLED`
|
- 当前取消流程:`ACCEPTED -> CANCELLED`
|
||||||
|
- 历史兼容:保留 `PENDING` 状态枚举,便于兼容旧数据与旧任务记录
|
||||||
|
|
||||||
非法流转会返回 `409` 冲突错误(中文消息)。
|
非法流转会返回 `409` 冲突错误(中文消息)。
|
||||||
|
|
||||||
## 3. 角色权限
|
## 3. 角色权限
|
||||||
|
|
||||||
- 医生/主任/组长:发布任务、取消自己创建的任务
|
- 系统管理员/医院管理员/医生/主任/组长:发布任务时必须直接指定接收工程师,并且只能取消自己创建的任务
|
||||||
- 工程师:接收任务、完成自己接收的任务
|
- 工程师:不能再执行“接收”动作,只能完成指派给自己的任务
|
||||||
- 其他角色:默认拒绝
|
- 其他角色:默认拒绝
|
||||||
|
|
||||||
补充:
|
补充:
|
||||||
|
|
||||||
|
- `GET /b/tasks/engineers`:返回当前角色可选的接收工程师列表,系统管理员可按医院筛选。
|
||||||
|
- `GET /b/tasks`:返回当前角色可见的调压记录列表,系统管理员可按医院筛选。
|
||||||
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
|
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
|
||||||
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
|
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
|
||||||
|
|
||||||
## 4. 事件触发
|
## 4. 记录列表
|
||||||
|
|
||||||
|
- 后台任务页不再承担手工发布入口,只展示调压记录。
|
||||||
|
- 记录维度按 `TaskItem` 展开,每条记录会携带:
|
||||||
|
- 任务状态
|
||||||
|
- 患者信息
|
||||||
|
- 手术名称
|
||||||
|
- 设备信息
|
||||||
|
- 旧压力 / 目标压力 / 当前压力(均为字符串挡位标签)
|
||||||
|
- 创建人 / 接收人 / 发布时间
|
||||||
|
|
||||||
|
## 5. 事件触发
|
||||||
|
|
||||||
状态变化后会发出事件:
|
状态变化后会发出事件:
|
||||||
|
|
||||||
- `task.published`
|
- `task.published`
|
||||||
- `task.accepted`
|
|
||||||
- `task.completed`
|
- `task.completed`
|
||||||
- `task.cancelled`
|
- `task.cancelled`
|
||||||
|
|
||||||
用于后续接入微信通知或消息中心。
|
用于后续接入微信通知或消息中心。
|
||||||
|
|
||||||
## 5. 完成任务时的设备同步
|
## 6. 完成任务时的设备同步
|
||||||
|
|
||||||
`completeTask` 在单事务中执行:
|
`completeTask` 在单事务中执行:
|
||||||
|
|
||||||
@ -43,3 +56,8 @@
|
|||||||
3. 批量更新关联 `Device.currentPressure`
|
3. 批量更新关联 `Device.currentPressure`
|
||||||
|
|
||||||
确保任务状态与设备压力一致性。
|
确保任务状态与设备压力一致性。
|
||||||
|
|
||||||
|
补充:
|
||||||
|
|
||||||
|
- `publishTask` 只负责生成任务和目标挡位,不会立刻修改设备当前压力。
|
||||||
|
- 只有工程师完成任务后,目标挡位才会回写到设备实例。
|
||||||
|
|||||||
71
docs/uploads.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# 上传资产模块说明(`src/uploads`)
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
- 提供图片、视频、文件的统一上传入口。
|
||||||
|
- 为 B 端“影像库/视频库/文件库”页面提供分页查询。
|
||||||
|
- 为患者手术表单中的术前资料、植入物标签上传提供复用能力。
|
||||||
|
|
||||||
|
## 2. 数据模型
|
||||||
|
|
||||||
|
新增 `UploadAsset` 表,保存上传文件元数据:
|
||||||
|
|
||||||
|
- `hospitalId`:医院归属
|
||||||
|
- `creatorId`:上传人
|
||||||
|
- `type`:`IMAGE / VIDEO / FILE`
|
||||||
|
- `originalName`:原始文件名
|
||||||
|
- `fileName`:服务端生成文件名
|
||||||
|
- `storagePath`:相对存储路径
|
||||||
|
- `url`:公开访问地址,前端直接用于预览
|
||||||
|
- `mimeType`:文件 MIME 类型
|
||||||
|
- `fileSize`:文件大小(字节)
|
||||||
|
|
||||||
|
文件本体默认落盘到:
|
||||||
|
|
||||||
|
- 公开目录:`storage/uploads`
|
||||||
|
- 临时目录:`storage/tmp-uploads`
|
||||||
|
- 最终目录规则:`storage/uploads/YYYY/MM/DD`
|
||||||
|
- 最终文件名规则:`YYYYMMDDHHmmss-原文件名`
|
||||||
|
- 图片压缩后扩展名统一为 `.webp`
|
||||||
|
- 视频压缩后扩展名统一为 `.mp4`
|
||||||
|
- 如同一秒内出现同名文件,会自动追加 `-1`、`-2` 防止覆盖
|
||||||
|
|
||||||
|
## 3. 接口
|
||||||
|
|
||||||
|
- `POST /b/uploads`
|
||||||
|
- 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN / DIRECTOR / LEADER / DOCTOR`
|
||||||
|
- 表单字段:
|
||||||
|
- `file`:二进制文件
|
||||||
|
- `hospitalId`:仅 `SYSTEM_ADMIN` 上传时必填
|
||||||
|
- `GET /b/uploads`
|
||||||
|
- 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN`
|
||||||
|
- 查询参数:
|
||||||
|
- `keyword`
|
||||||
|
- `type`
|
||||||
|
- `hospitalId`:仅 `SYSTEM_ADMIN` 可选
|
||||||
|
- `page`
|
||||||
|
- `pageSize`
|
||||||
|
|
||||||
|
## 4. 使用说明
|
||||||
|
|
||||||
|
- 患者手术表单中的“术前 CT 影像/资料”支持直接上传,上传成功后自动回填 `type/name/url`。
|
||||||
|
- 设备表单中的“植入物标签”支持直接上传图片,上传成功后自动回填 `labelImageUrl`。
|
||||||
|
- 患者详情页会直接预览术前图片、视频和设备标签。
|
||||||
|
- 单独新增“影像库”页面,按图片/视频/文件分页查看所有上传资产。
|
||||||
|
- 页面访问权限仅 `SYSTEM_ADMIN / HOSPITAL_ADMIN`
|
||||||
|
|
||||||
|
## 5. 压缩策略
|
||||||
|
|
||||||
|
- 图片上传后会自动压缩并统一转成 `webp`:
|
||||||
|
- 自动纠正旋转方向
|
||||||
|
- 最大边限制为 `2560`
|
||||||
|
- 返回的 `mimeType` 为 `image/webp`
|
||||||
|
- 视频上传后会自动压缩并统一转成 `mp4`:
|
||||||
|
- 最大边限制为 `1280`
|
||||||
|
- 视频编码为 `H.264`
|
||||||
|
- 音频编码为 `AAC`
|
||||||
|
- 返回的 `mimeType` 为 `video/mp4`
|
||||||
|
- 普通文件类型不做转码,按原文件保存。
|
||||||
|
- 如果本地 `pnpm install` 屏蔽了依赖安装脚本,`ffmpeg-static` 二进制不会自动落盘,视频压缩会失败。
|
||||||
|
- 这种情况下手动执行:
|
||||||
|
- `node node_modules/.pnpm/ffmpeg-static@5.3.0/node_modules/ffmpeg-static/install.js`
|
||||||
@ -18,8 +18,10 @@
|
|||||||
|
|
||||||
- 医院内数据按 `hospitalId` 强隔离。
|
- 医院内数据按 `hospitalId` 强隔离。
|
||||||
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
|
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
|
||||||
- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。
|
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可执行用户创建、编辑、删除。
|
||||||
- `DIRECTOR` 可创建、查看、编辑、删除本科室医生,但不能跨科室操作,也不能把医生改成其他角色。
|
- `DIRECTOR` 仅可只读查看本科室下级医生/组长。
|
||||||
|
- `LEADER` 仅可只读查看本小组医生列表。
|
||||||
|
- `HOSPITAL_ADMIN` 仅可操作本院非管理员账号。
|
||||||
- 用户组织字段校验:
|
- 用户组织字段校验:
|
||||||
- 院管/医生/工程师等需有医院归属;
|
- 院管/医生/工程师等需有医院归属;
|
||||||
- 主任/组长需有科室/小组等必要归属;
|
- 主任/组长需有科室/小组等必要归属;
|
||||||
@ -32,12 +34,22 @@
|
|||||||
- `GET /users`、`GET /users/:id`、`PATCH /users/:id`、`DELETE /users/:id`
|
- `GET /users`、`GET /users/:id`、`PATCH /users/:id`、`DELETE /users/:id`
|
||||||
- `POST /b/users/:id/assign-engineer-hospital`
|
- `POST /b/users/:id/assign-engineer-hospital`
|
||||||
|
|
||||||
|
其中院管侧的常用链路为:
|
||||||
|
|
||||||
|
- `POST /users`:创建本院用户
|
||||||
|
- `GET /users/:id`:查看本院用户详情
|
||||||
|
- `PATCH /users/:id`:修改本院用户信息
|
||||||
|
- `DELETE /users/:id`:删除本院无关联、且非管理员用户
|
||||||
|
|
||||||
其中主任侧的常用链路为:
|
其中主任侧的常用链路为:
|
||||||
|
|
||||||
- `POST /users`:创建本科室医生
|
- `GET /users`:查看本科室下级医生/组长
|
||||||
- `GET /users/:id`:查看本科室医生详情
|
- `GET /users/:id`:查看本科室下级详情
|
||||||
- `PATCH /users/:id`:修改本科室医生信息
|
|
||||||
- `DELETE /users/:id`:删除无关联数据的本科室医生
|
其中组长侧的常用链路为:
|
||||||
|
|
||||||
|
- `GET /users`:查看本小组医生列表
|
||||||
|
- `GET /users/:id`:查看本小组医生详情
|
||||||
|
|
||||||
## 5. 开发改造建议
|
## 5. 开发改造建议
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate && node prisma/seed.mjs",
|
"test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate",
|
||||||
"test:e2e": "pnpm test:e2e:prepare && NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --runInBand",
|
"test:e2e": "pnpm test:e2e:prepare && NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --runInBand",
|
||||||
"test:e2e:watch": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --watch"
|
"test:e2e:watch": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --watch"
|
||||||
},
|
},
|
||||||
@ -30,10 +30,13 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"swagger-ui-express": "^5.0.1"
|
"swagger-ui-express": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -44,6 +47,7 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
|||||||
388
pnpm-lock.yaml
generated
@ -44,9 +44,15 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.3.1
|
specifier: ^17.3.1
|
||||||
version: 17.3.1
|
version: 17.3.1
|
||||||
|
ffmpeg-static:
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: ^9.0.3
|
specifier: ^9.0.3
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
|
multer:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.20.0
|
specifier: ^8.20.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
@ -56,6 +62,9 @@ importers:
|
|||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
sharp:
|
||||||
|
specifier: ^0.34.5
|
||||||
|
version: 0.34.5
|
||||||
swagger-ui-express:
|
swagger-ui-express:
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.1(express@5.2.1)
|
version: 5.0.1(express@5.2.1)
|
||||||
@ -81,6 +90,9 @@ importers:
|
|||||||
'@types/jsonwebtoken':
|
'@types/jsonwebtoken':
|
||||||
specifier: ^9.0.10
|
specifier: ^9.0.10
|
||||||
version: 9.0.10
|
version: 9.0.10
|
||||||
|
'@types/multer':
|
||||||
|
specifier: ^2.1.0
|
||||||
|
version: 2.1.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.7
|
specifier: ^22.10.7
|
||||||
version: 22.19.15
|
version: 22.19.15
|
||||||
@ -342,6 +354,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@derhuerst/http-basic@8.2.4':
|
||||||
|
resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
'@electric-sql/pglite-socket@0.0.20':
|
'@electric-sql/pglite-socket@0.0.20':
|
||||||
resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==}
|
resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -371,6 +387,159 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: ^4
|
hono: ^4
|
||||||
|
|
||||||
|
'@img/colour@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@inquirer/ansi@1.0.2':
|
'@inquirer/ansi@1.0.2':
|
||||||
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
|
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -932,6 +1101,12 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
|
'@types/multer@2.1.0':
|
||||||
|
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
|
||||||
|
|
||||||
|
'@types/node@10.17.60':
|
||||||
|
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
|
||||||
|
|
||||||
'@types/node@22.19.15':
|
'@types/node@22.19.15':
|
||||||
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
||||||
|
|
||||||
@ -1147,6 +1322,10 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
agent-base@6.0.2:
|
||||||
|
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||||
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
ajv-formats@2.1.1:
|
ajv-formats@2.1.1:
|
||||||
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -1368,6 +1547,9 @@ packages:
|
|||||||
caniuse-lite@1.0.30001778:
|
caniuse-lite@1.0.30001778:
|
||||||
resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==}
|
resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==}
|
||||||
|
|
||||||
|
caseless@0.12.0:
|
||||||
|
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -1573,6 +1755,10 @@ packages:
|
|||||||
destr@2.0.5:
|
destr@2.0.5:
|
||||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
detect-newline@3.1.0:
|
detect-newline@3.1.0:
|
||||||
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
|
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1633,6 +1819,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
|
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
env-paths@2.2.1:
|
||||||
|
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
error-ex@1.3.4:
|
error-ex@1.3.4:
|
||||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||||
|
|
||||||
@ -1736,6 +1926,10 @@ packages:
|
|||||||
fb-watchman@2.0.2:
|
fb-watchman@2.0.2:
|
||||||
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
|
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
|
||||||
|
|
||||||
|
ffmpeg-static@5.3.0:
|
||||||
|
resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
file-type@21.3.0:
|
file-type@21.3.0:
|
||||||
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
|
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@ -1896,9 +2090,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
http-response-object@3.0.2:
|
||||||
|
resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
|
||||||
|
|
||||||
http-status-codes@2.3.0:
|
http-status-codes@2.3.0:
|
||||||
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
|
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
|
||||||
|
|
||||||
|
https-proxy-agent@5.0.1:
|
||||||
|
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
human-signals@2.1.0:
|
human-signals@2.1.0:
|
||||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||||
engines: {node: '>=10.17.0'}
|
engines: {node: '>=10.17.0'}
|
||||||
@ -2465,6 +2666,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
parse-cache-control@1.0.1:
|
||||||
|
resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==}
|
||||||
|
|
||||||
parse-json@5.2.0:
|
parse-json@5.2.0:
|
||||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -2639,6 +2843,10 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
progress@2.0.3:
|
||||||
|
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
proper-lockfile@4.1.2:
|
proper-lockfile@4.1.2:
|
||||||
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
|
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
|
||||||
|
|
||||||
@ -2778,6 +2986,10 @@ packages:
|
|||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -3491,6 +3703,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.9
|
'@jridgewell/trace-mapping': 0.3.9
|
||||||
|
|
||||||
|
'@derhuerst/http-basic@8.2.4':
|
||||||
|
dependencies:
|
||||||
|
caseless: 0.12.0
|
||||||
|
concat-stream: 2.0.0
|
||||||
|
http-response-object: 3.0.2
|
||||||
|
parse-cache-control: 1.0.1
|
||||||
|
|
||||||
'@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)':
|
'@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@electric-sql/pglite': 0.3.15
|
'@electric-sql/pglite': 0.3.15
|
||||||
@ -3521,6 +3740,102 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.11.4
|
hono: 4.11.4
|
||||||
|
|
||||||
|
'@img/colour@1.1.0': {}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.5':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/runtime': 1.9.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@inquirer/ansi@1.0.2': {}
|
'@inquirer/ansi@1.0.2': {}
|
||||||
|
|
||||||
'@inquirer/checkbox@4.3.2(@types/node@22.19.15)':
|
'@inquirer/checkbox@4.3.2(@types/node@22.19.15)':
|
||||||
@ -4247,6 +4562,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
|
'@types/multer@2.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/express': 5.0.6
|
||||||
|
|
||||||
|
'@types/node@10.17.60': {}
|
||||||
|
|
||||||
'@types/node@22.19.15':
|
'@types/node@22.19.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@ -4452,6 +4773,12 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.16.0: {}
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
|
agent-base@6.0.2:
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
ajv-formats@2.1.1(ajv@8.18.0):
|
ajv-formats@2.1.1(ajv@8.18.0):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ajv: 8.18.0
|
ajv: 8.18.0
|
||||||
@ -4699,6 +5026,8 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001778: {}
|
caniuse-lite@1.0.30001778: {}
|
||||||
|
|
||||||
|
caseless@0.12.0: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@ -4864,6 +5193,8 @@ snapshots:
|
|||||||
|
|
||||||
destr@2.0.5: {}
|
destr@2.0.5: {}
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
detect-newline@3.1.0: {}
|
detect-newline@3.1.0: {}
|
||||||
|
|
||||||
dezalgo@1.0.4:
|
dezalgo@1.0.4:
|
||||||
@ -4913,6 +5244,8 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.0
|
tapable: 2.3.0
|
||||||
|
|
||||||
|
env-paths@2.2.1: {}
|
||||||
|
|
||||||
error-ex@1.3.4:
|
error-ex@1.3.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish: 0.2.1
|
is-arrayish: 0.2.1
|
||||||
@ -5035,6 +5368,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
bser: 2.1.1
|
bser: 2.1.1
|
||||||
|
|
||||||
|
ffmpeg-static@5.3.0:
|
||||||
|
dependencies:
|
||||||
|
'@derhuerst/http-basic': 8.2.4
|
||||||
|
env-paths: 2.2.1
|
||||||
|
https-proxy-agent: 5.0.1
|
||||||
|
progress: 2.0.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
file-type@21.3.0:
|
file-type@21.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tokenizer/inflate': 0.4.1
|
'@tokenizer/inflate': 0.4.1
|
||||||
@ -5229,8 +5571,19 @@ snapshots:
|
|||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
|
http-response-object@3.0.2:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 10.17.60
|
||||||
|
|
||||||
http-status-codes@2.3.0: {}
|
http-status-codes@2.3.0: {}
|
||||||
|
|
||||||
|
https-proxy-agent@5.0.1:
|
||||||
|
dependencies:
|
||||||
|
agent-base: 6.0.2
|
||||||
|
debug: 4.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
human-signals@2.1.0: {}
|
human-signals@2.1.0: {}
|
||||||
|
|
||||||
iconv-lite@0.7.2:
|
iconv-lite@0.7.2:
|
||||||
@ -5926,6 +6279,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
|
|
||||||
|
parse-cache-control@1.0.1: {}
|
||||||
|
|
||||||
parse-json@5.2.0:
|
parse-json@5.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
@ -6076,6 +6431,8 @@ snapshots:
|
|||||||
- react
|
- react
|
||||||
- react-dom
|
- react-dom
|
||||||
|
|
||||||
|
progress@2.0.3: {}
|
||||||
|
|
||||||
proper-lockfile@4.1.2:
|
proper-lockfile@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@ -6223,6 +6580,37 @@ snapshots:
|
|||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
|
sharp@0.34.5:
|
||||||
|
dependencies:
|
||||||
|
'@img/colour': 1.1.0
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
semver: 7.7.4
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-darwin-arm64': 0.34.5
|
||||||
|
'@img/sharp-darwin-x64': 0.34.5
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.2.4
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
||||||
|
'@img/sharp-linux-arm': 0.34.5
|
||||||
|
'@img/sharp-linux-arm64': 0.34.5
|
||||||
|
'@img/sharp-linux-ppc64': 0.34.5
|
||||||
|
'@img/sharp-linux-riscv64': 0.34.5
|
||||||
|
'@img/sharp-linux-s390x': 0.34.5
|
||||||
|
'@img/sharp-linux-x64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-arm64': 0.34.5
|
||||||
|
'@img/sharp-linuxmusl-x64': 0.34.5
|
||||||
|
'@img/sharp-wasm32': 0.34.5
|
||||||
|
'@img/sharp-win32-arm64': 0.34.5
|
||||||
|
'@img/sharp-win32-ia32': 0.34.5
|
||||||
|
'@img/sharp-win32-x64': 0.34.5
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
|
|||||||
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- ffmpeg-static
|
||||||
|
- sharp
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Patient"
|
||||||
|
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "creatorId" INTEGER;
|
||||||
|
|
||||||
|
-- Backfill
|
||||||
|
UPDATE "Patient"
|
||||||
|
SET "creatorId" = "doctorId"
|
||||||
|
WHERE "creatorId" IS NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Patient"
|
||||||
|
ALTER COLUMN "creatorId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Patient_creatorId_idx" ON "Patient"("creatorId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "Device_snCode_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Device" DROP COLUMN "snCode";
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Device" ALTER COLUMN "currentPressure" SET DATA TYPE TEXT,
|
||||||
|
ALTER COLUMN "initialPressure" SET DATA TYPE TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ImplantCatalog" ALTER COLUMN "pressureLevels" SET DATA TYPE TEXT[];
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PatientSurgery" ADD COLUMN "surgeonId" INTEGER;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TaskItem" ALTER COLUMN "oldPressure" SET DATA TYPE TEXT,
|
||||||
|
ALTER COLUMN "targetPressure" SET DATA TYPE TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PatientSurgery_surgeonId_idx" ON "PatientSurgery"("surgeonId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PatientSurgery" ADD CONSTRAINT "PatientSurgery_surgeonId_fkey" FOREIGN KEY ("surgeonId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UploadAssetType" AS ENUM ('IMAGE', 'VIDEO', 'FILE');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UploadAsset" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"hospitalId" INTEGER NOT NULL,
|
||||||
|
"creatorId" INTEGER NOT NULL,
|
||||||
|
"type" "UploadAssetType" NOT NULL,
|
||||||
|
"originalName" TEXT NOT NULL,
|
||||||
|
"fileName" TEXT NOT NULL,
|
||||||
|
"storagePath" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"mimeType" TEXT NOT NULL,
|
||||||
|
"fileSize" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "UploadAsset_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UploadAsset_storagePath_key" ON "UploadAsset"("storagePath");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UploadAsset_hospitalId_type_createdAt_idx" ON "UploadAsset"("hospitalId", "type", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UploadAsset_creatorId_createdAt_idx" ON "UploadAsset"("creatorId", "createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UploadAsset" ADD CONSTRAINT "UploadAsset_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UploadAsset" ADD CONSTRAINT "UploadAsset_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@ -46,6 +46,13 @@ enum DictionaryType {
|
|||||||
DISTAL_SHUNT_DIRECTION
|
DISTAL_SHUNT_DIRECTION
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 上传资产类型:用于图库/视频库分类。
|
||||||
|
enum UploadAssetType {
|
||||||
|
IMAGE
|
||||||
|
VIDEO
|
||||||
|
FILE
|
||||||
|
}
|
||||||
|
|
||||||
// 医院主表:多租户顶层实体。
|
// 医院主表:多租户顶层实体。
|
||||||
model Hospital {
|
model Hospital {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
@ -54,6 +61,7 @@ model Hospital {
|
|||||||
users User[]
|
users User[]
|
||||||
patients Patient[]
|
patients Patient[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
uploads UploadAsset[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 科室表:归属于医院。
|
// 科室表:归属于医院。
|
||||||
@ -98,8 +106,11 @@ model User {
|
|||||||
// 小组删除必须先清理成员,避免静默把用户 groupId 置空。
|
// 小组删除必须先清理成员,避免静默把用户 groupId 置空。
|
||||||
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
|
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
|
||||||
doctorPatients Patient[] @relation("DoctorPatients")
|
doctorPatients Patient[] @relation("DoctorPatients")
|
||||||
|
createdPatients Patient[] @relation("PatientCreator")
|
||||||
createdTasks Task[] @relation("TaskCreator")
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
acceptedTasks Task[] @relation("TaskEngineer")
|
acceptedTasks Task[] @relation("TaskEngineer")
|
||||||
|
surgeonSurgeries PatientSurgery[] @relation("SurgerySurgeon")
|
||||||
|
createdUploads UploadAsset[] @relation("UploadCreator")
|
||||||
|
|
||||||
@@unique([phone, role, hospitalId])
|
@@unique([phone, role, hospitalId])
|
||||||
@@index([phone])
|
@@index([phone])
|
||||||
@ -112,6 +123,7 @@ model User {
|
|||||||
model Patient {
|
model Patient {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
// 住院号:用于院内患者检索与病案关联。
|
// 住院号:用于院内患者检索与病案关联。
|
||||||
inpatientNo String?
|
inpatientNo String?
|
||||||
// 项目名称:用于区分患者所属项目/课题。
|
// 项目名称:用于区分患者所属项目/课题。
|
||||||
@ -121,14 +133,17 @@ model Patient {
|
|||||||
idCard String
|
idCard String
|
||||||
hospitalId Int
|
hospitalId Int
|
||||||
doctorId Int
|
doctorId Int
|
||||||
|
creatorId 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])
|
||||||
|
creator User @relation("PatientCreator", fields: [creatorId], references: [id])
|
||||||
surgeries PatientSurgery[]
|
surgeries PatientSurgery[]
|
||||||
devices Device[]
|
devices Device[]
|
||||||
|
|
||||||
@@index([phone, idCard])
|
@@index([phone, idCard])
|
||||||
@@index([hospitalId, doctorId])
|
@@index([hospitalId, doctorId])
|
||||||
@@index([inpatientNo])
|
@@index([inpatientNo])
|
||||||
|
@@index([creatorId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 患者手术表:保存每次分流/复手术档案。
|
// 患者手术表:保存每次分流/复手术档案。
|
||||||
@ -137,6 +152,7 @@ model PatientSurgery {
|
|||||||
patientId Int
|
patientId Int
|
||||||
surgeryDate DateTime
|
surgeryDate DateTime
|
||||||
surgeryName String
|
surgeryName String
|
||||||
|
surgeonId Int?
|
||||||
surgeonName String
|
surgeonName String
|
||||||
// 术前测压:部分患者可为空。
|
// 术前测压:部分患者可为空。
|
||||||
preOpPressure Int?
|
preOpPressure Int?
|
||||||
@ -151,9 +167,11 @@ model PatientSurgery {
|
|||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||||
|
surgeon User? @relation("SurgerySurgeon", fields: [surgeonId], references: [id], onDelete: SetNull)
|
||||||
devices Device[]
|
devices Device[]
|
||||||
|
|
||||||
@@index([patientId, surgeryDate])
|
@@index([patientId, surgeryDate])
|
||||||
|
@@index([surgeonId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 植入物型号字典:供前端单选型号后自动回填厂家与名称。
|
// 植入物型号字典:供前端单选型号后自动回填厂家与名称。
|
||||||
@ -163,7 +181,7 @@ model ImplantCatalog {
|
|||||||
manufacturer String
|
manufacturer String
|
||||||
name String
|
name String
|
||||||
// 可调压器械的可选挡位,由系统管理员维护。
|
// 可调压器械的可选挡位,由系统管理员维护。
|
||||||
pressureLevels Int[] @default([])
|
pressureLevels String[] @default([])
|
||||||
isPressureAdjustable Boolean @default(true)
|
isPressureAdjustable Boolean @default(true)
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -185,11 +203,30 @@ model DictionaryItem {
|
|||||||
@@index([type, enabled, sortOrder])
|
@@index([type, enabled, sortOrder])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 上传资产表:保存图片/视频/文件元数据,供图库与患者表单复用。
|
||||||
|
model UploadAsset {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
hospitalId Int
|
||||||
|
creatorId Int
|
||||||
|
type UploadAssetType
|
||||||
|
originalName String
|
||||||
|
fileName String
|
||||||
|
storagePath String @unique
|
||||||
|
url String
|
||||||
|
mimeType String
|
||||||
|
fileSize Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||||
|
creator User @relation("UploadCreator", fields: [creatorId], references: [id])
|
||||||
|
|
||||||
|
@@index([hospitalId, type, createdAt])
|
||||||
|
@@index([creatorId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
// 设备表:每次手术植入的设备实例,保留当前压力与历史调压记录。
|
// 设备表:每次手术植入的设备实例,保留当前压力与历史调压记录。
|
||||||
model Device {
|
model Device {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
snCode String @unique
|
currentPressure String
|
||||||
currentPressure Int
|
|
||||||
status DeviceStatus @default(ACTIVE)
|
status DeviceStatus @default(ACTIVE)
|
||||||
patientId Int
|
patientId Int
|
||||||
surgeryId Int?
|
surgeryId Int?
|
||||||
@ -205,7 +242,7 @@ model Device {
|
|||||||
proximalPunctureAreas String[] @default([])
|
proximalPunctureAreas String[] @default([])
|
||||||
valvePlacementSites String[] @default([])
|
valvePlacementSites String[] @default([])
|
||||||
distalShuntDirection String?
|
distalShuntDirection String?
|
||||||
initialPressure Int?
|
initialPressure String?
|
||||||
implantNotes String?
|
implantNotes String?
|
||||||
labelImageUrl String?
|
labelImageUrl String?
|
||||||
patient Patient @relation(fields: [patientId], references: [id])
|
patient Patient @relation(fields: [patientId], references: [id])
|
||||||
@ -240,8 +277,8 @@ model TaskItem {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
taskId Int
|
taskId Int
|
||||||
deviceId Int
|
deviceId Int
|
||||||
oldPressure Int
|
oldPressure String
|
||||||
targetPressure Int
|
targetPressure String
|
||||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
device Device @relation(fields: [deviceId], references: [id])
|
device Device @relation(fields: [deviceId], references: [id])
|
||||||
|
|
||||||
|
|||||||
191
prisma/seed.mjs
@ -64,6 +64,7 @@ async function upsertUserByOpenId(openId, data) {
|
|||||||
async function ensurePatient({
|
async function ensurePatient({
|
||||||
hospitalId,
|
hospitalId,
|
||||||
doctorId,
|
doctorId,
|
||||||
|
creatorId,
|
||||||
name,
|
name,
|
||||||
inpatientNo = null,
|
inpatientNo = null,
|
||||||
projectName = null,
|
projectName = null,
|
||||||
@ -81,13 +82,14 @@ async function ensurePatient({
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
if (
|
if (
|
||||||
existing.doctorId !== doctorId ||
|
existing.doctorId !== doctorId ||
|
||||||
|
existing.creatorId !== creatorId ||
|
||||||
existing.name !== name ||
|
existing.name !== name ||
|
||||||
existing.inpatientNo !== inpatientNo ||
|
existing.inpatientNo !== inpatientNo ||
|
||||||
existing.projectName !== projectName
|
existing.projectName !== projectName
|
||||||
) {
|
) {
|
||||||
return prisma.patient.update({
|
return prisma.patient.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { doctorId, name, inpatientNo, projectName },
|
data: { doctorId, creatorId, name, inpatientNo, projectName },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return existing;
|
return existing;
|
||||||
@ -97,6 +99,7 @@ async function ensurePatient({
|
|||||||
data: {
|
data: {
|
||||||
hospitalId,
|
hospitalId,
|
||||||
doctorId,
|
doctorId,
|
||||||
|
creatorId,
|
||||||
name,
|
name,
|
||||||
inpatientNo,
|
inpatientNo,
|
||||||
projectName,
|
projectName,
|
||||||
@ -216,6 +219,63 @@ async function ensurePatientSurgery({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureDevice({
|
||||||
|
patientId,
|
||||||
|
surgeryId,
|
||||||
|
implantCatalogId,
|
||||||
|
currentPressure,
|
||||||
|
status,
|
||||||
|
implantModel,
|
||||||
|
implantManufacturer,
|
||||||
|
implantName,
|
||||||
|
isPressureAdjustable,
|
||||||
|
isAbandoned,
|
||||||
|
shuntMode,
|
||||||
|
proximalPunctureAreas,
|
||||||
|
valvePlacementSites,
|
||||||
|
distalShuntDirection,
|
||||||
|
initialPressure,
|
||||||
|
implantNotes,
|
||||||
|
labelImageUrl,
|
||||||
|
}) {
|
||||||
|
const existing = await prisma.device.findFirst({
|
||||||
|
where: {
|
||||||
|
patientId,
|
||||||
|
surgeryId,
|
||||||
|
implantNotes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
patientId,
|
||||||
|
surgeryId,
|
||||||
|
implantCatalogId,
|
||||||
|
currentPressure,
|
||||||
|
status,
|
||||||
|
implantModel,
|
||||||
|
implantManufacturer,
|
||||||
|
implantName,
|
||||||
|
isPressureAdjustable,
|
||||||
|
isAbandoned,
|
||||||
|
shuntMode,
|
||||||
|
proximalPunctureAreas,
|
||||||
|
valvePlacementSites,
|
||||||
|
distalShuntDirection,
|
||||||
|
initialPressure,
|
||||||
|
implantNotes,
|
||||||
|
labelImageUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return prisma.device.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.device.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12);
|
const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12);
|
||||||
|
|
||||||
@ -395,6 +455,7 @@ async function main() {
|
|||||||
const patientA1 = await ensurePatient({
|
const patientA1 = await ensurePatient({
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
doctorId: doctorA.id,
|
doctorId: doctorA.id,
|
||||||
|
creatorId: doctorA.id,
|
||||||
name: 'Seed Patient A1',
|
name: 'Seed Patient A1',
|
||||||
inpatientNo: 'ZYH-A-0001',
|
inpatientNo: 'ZYH-A-0001',
|
||||||
projectName: '脑积水随访项目-A',
|
projectName: '脑积水随访项目-A',
|
||||||
@ -405,6 +466,7 @@ async function main() {
|
|||||||
const patientA2 = await ensurePatient({
|
const patientA2 = await ensurePatient({
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
doctorId: doctorA2.id,
|
doctorId: doctorA2.id,
|
||||||
|
creatorId: doctorA2.id,
|
||||||
name: 'Seed Patient A2',
|
name: 'Seed Patient A2',
|
||||||
inpatientNo: 'ZYH-A-0002',
|
inpatientNo: 'ZYH-A-0002',
|
||||||
projectName: '脑积水随访项目-A',
|
projectName: '脑积水随访项目-A',
|
||||||
@ -415,6 +477,7 @@ async function main() {
|
|||||||
const patientA3 = await ensurePatient({
|
const patientA3 = await ensurePatient({
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
doctorId: doctorA3.id,
|
doctorId: doctorA3.id,
|
||||||
|
creatorId: doctorA3.id,
|
||||||
name: 'Seed Patient A3',
|
name: 'Seed Patient A3',
|
||||||
inpatientNo: 'ZYH-A-0003',
|
inpatientNo: 'ZYH-A-0003',
|
||||||
projectName: '脑积水随访项目-A',
|
projectName: '脑积水随访项目-A',
|
||||||
@ -425,6 +488,7 @@ async function main() {
|
|||||||
const patientB1 = await ensurePatient({
|
const patientB1 = await ensurePatient({
|
||||||
hospitalId: hospitalB.id,
|
hospitalId: hospitalB.id,
|
||||||
doctorId: doctorB.id,
|
doctorId: doctorB.id,
|
||||||
|
creatorId: doctorB.id,
|
||||||
name: 'Seed Patient B1',
|
name: 'Seed Patient B1',
|
||||||
inpatientNo: 'ZYH-B-0001',
|
inpatientNo: 'ZYH-B-0001',
|
||||||
projectName: '脑积水随访项目-B',
|
projectName: '脑积水随访项目-B',
|
||||||
@ -510,9 +574,7 @@ async function main() {
|
|||||||
hydrocephalusTypes: ['高压性'],
|
hydrocephalusTypes: ['高压性'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const deviceA1 = await prisma.device.upsert({
|
const deviceA1 = await ensureDevice({
|
||||||
where: { snCode: 'SEED-SN-A-001' },
|
|
||||||
update: {
|
|
||||||
patientId: patientA1.id,
|
patientId: patientA1.id,
|
||||||
surgeryId: surgeryA1New.id,
|
surgeryId: surgeryA1New.id,
|
||||||
implantCatalogId: adjustableCatalog.id,
|
implantCatalogId: adjustableCatalog.id,
|
||||||
@ -530,32 +592,9 @@ async function main() {
|
|||||||
initialPressure: 118,
|
initialPressure: 118,
|
||||||
implantNotes: 'Seed A1 当前在用设备',
|
implantNotes: 'Seed A1 当前在用设备',
|
||||||
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
|
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
|
||||||
},
|
|
||||||
create: {
|
|
||||||
snCode: 'SEED-SN-A-001',
|
|
||||||
patientId: patientA1.id,
|
|
||||||
surgeryId: surgeryA1New.id,
|
|
||||||
implantCatalogId: adjustableCatalog.id,
|
|
||||||
currentPressure: 118,
|
|
||||||
status: DeviceStatus.ACTIVE,
|
|
||||||
implantModel: adjustableCatalog.modelCode,
|
|
||||||
implantManufacturer: adjustableCatalog.manufacturer,
|
|
||||||
implantName: adjustableCatalog.name,
|
|
||||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
|
||||||
isAbandoned: false,
|
|
||||||
shuntMode: 'VPS',
|
|
||||||
proximalPunctureAreas: ['额角'],
|
|
||||||
valvePlacementSites: ['耳后'],
|
|
||||||
distalShuntDirection: '腹腔',
|
|
||||||
initialPressure: 118,
|
|
||||||
implantNotes: 'Seed A1 当前在用设备',
|
|
||||||
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deviceA2 = await prisma.device.upsert({
|
const deviceA2 = await ensureDevice({
|
||||||
where: { snCode: 'SEED-SN-A-002' },
|
|
||||||
update: {
|
|
||||||
patientId: patientA2.id,
|
patientId: patientA2.id,
|
||||||
surgeryId: surgeryA2.id,
|
surgeryId: surgeryA2.id,
|
||||||
implantCatalogId: adjustableCatalog.id,
|
implantCatalogId: adjustableCatalog.id,
|
||||||
@ -573,32 +612,9 @@ async function main() {
|
|||||||
initialPressure: 112,
|
initialPressure: 112,
|
||||||
implantNotes: 'Seed A2 当前在用设备',
|
implantNotes: 'Seed A2 当前在用设备',
|
||||||
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
|
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
|
||||||
},
|
|
||||||
create: {
|
|
||||||
snCode: 'SEED-SN-A-002',
|
|
||||||
patientId: patientA2.id,
|
|
||||||
surgeryId: surgeryA2.id,
|
|
||||||
implantCatalogId: adjustableCatalog.id,
|
|
||||||
currentPressure: 112,
|
|
||||||
status: DeviceStatus.ACTIVE,
|
|
||||||
implantModel: adjustableCatalog.modelCode,
|
|
||||||
implantManufacturer: adjustableCatalog.manufacturer,
|
|
||||||
implantName: adjustableCatalog.name,
|
|
||||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
|
||||||
isAbandoned: false,
|
|
||||||
shuntMode: 'VPS',
|
|
||||||
proximalPunctureAreas: ['枕角'],
|
|
||||||
valvePlacementSites: ['胸前'],
|
|
||||||
distalShuntDirection: '腹腔',
|
|
||||||
initialPressure: 112,
|
|
||||||
implantNotes: 'Seed A2 当前在用设备',
|
|
||||||
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.device.upsert({
|
await ensureDevice({
|
||||||
where: { snCode: 'SEED-SN-A-003' },
|
|
||||||
update: {
|
|
||||||
patientId: patientA3.id,
|
patientId: patientA3.id,
|
||||||
surgeryId: surgeryA3.id,
|
surgeryId: surgeryA3.id,
|
||||||
implantCatalogId: adjustableCatalog.id,
|
implantCatalogId: adjustableCatalog.id,
|
||||||
@ -616,32 +632,9 @@ async function main() {
|
|||||||
initialPressure: 109,
|
initialPressure: 109,
|
||||||
implantNotes: 'Seed A3 当前在用设备',
|
implantNotes: 'Seed A3 当前在用设备',
|
||||||
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
|
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
|
||||||
},
|
|
||||||
create: {
|
|
||||||
snCode: 'SEED-SN-A-003',
|
|
||||||
patientId: patientA3.id,
|
|
||||||
surgeryId: surgeryA3.id,
|
|
||||||
implantCatalogId: adjustableCatalog.id,
|
|
||||||
currentPressure: 109,
|
|
||||||
status: DeviceStatus.ACTIVE,
|
|
||||||
implantModel: adjustableCatalog.modelCode,
|
|
||||||
implantManufacturer: adjustableCatalog.manufacturer,
|
|
||||||
implantName: adjustableCatalog.name,
|
|
||||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
|
||||||
isAbandoned: false,
|
|
||||||
shuntMode: 'LPS',
|
|
||||||
proximalPunctureAreas: ['腰穿'],
|
|
||||||
valvePlacementSites: ['腰背部'],
|
|
||||||
distalShuntDirection: '腹腔',
|
|
||||||
initialPressure: 109,
|
|
||||||
implantNotes: 'Seed A3 当前在用设备',
|
|
||||||
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deviceB1 = await prisma.device.upsert({
|
const deviceB1 = await ensureDevice({
|
||||||
where: { snCode: 'SEED-SN-B-001' },
|
|
||||||
update: {
|
|
||||||
patientId: patientB1.id,
|
patientId: patientB1.id,
|
||||||
surgeryId: surgeryB1.id,
|
surgeryId: surgeryB1.id,
|
||||||
implantCatalogId: adjustableCatalog.id,
|
implantCatalogId: adjustableCatalog.id,
|
||||||
@ -659,32 +652,9 @@ async function main() {
|
|||||||
initialPressure: 121,
|
initialPressure: 121,
|
||||||
implantNotes: 'Seed B1 当前在用设备',
|
implantNotes: 'Seed B1 当前在用设备',
|
||||||
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
|
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
|
||||||
},
|
|
||||||
create: {
|
|
||||||
snCode: 'SEED-SN-B-001',
|
|
||||||
patientId: patientB1.id,
|
|
||||||
surgeryId: surgeryB1.id,
|
|
||||||
implantCatalogId: adjustableCatalog.id,
|
|
||||||
currentPressure: 121,
|
|
||||||
status: DeviceStatus.ACTIVE,
|
|
||||||
implantModel: adjustableCatalog.modelCode,
|
|
||||||
implantManufacturer: adjustableCatalog.manufacturer,
|
|
||||||
implantName: adjustableCatalog.name,
|
|
||||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
|
||||||
isAbandoned: false,
|
|
||||||
shuntMode: 'VPS',
|
|
||||||
proximalPunctureAreas: ['额角'],
|
|
||||||
valvePlacementSites: ['耳后'],
|
|
||||||
distalShuntDirection: '腹腔',
|
|
||||||
initialPressure: 121,
|
|
||||||
implantNotes: 'Seed B1 当前在用设备',
|
|
||||||
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.device.upsert({
|
await ensureDevice({
|
||||||
where: { snCode: 'SEED-SN-A-004' },
|
|
||||||
update: {
|
|
||||||
patientId: patientA1.id,
|
patientId: patientA1.id,
|
||||||
surgeryId: surgeryA1Old.id,
|
surgeryId: surgeryA1Old.id,
|
||||||
implantCatalogId: adjustableCatalog.id,
|
implantCatalogId: adjustableCatalog.id,
|
||||||
@ -702,27 +672,6 @@ async function main() {
|
|||||||
initialPressure: 130,
|
initialPressure: 130,
|
||||||
implantNotes: 'Seed A1 弃用历史设备',
|
implantNotes: 'Seed A1 弃用历史设备',
|
||||||
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
|
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
|
||||||
},
|
|
||||||
create: {
|
|
||||||
snCode: 'SEED-SN-A-004',
|
|
||||||
patientId: patientA1.id,
|
|
||||||
surgeryId: surgeryA1Old.id,
|
|
||||||
implantCatalogId: adjustableCatalog.id,
|
|
||||||
currentPressure: 130,
|
|
||||||
status: DeviceStatus.INACTIVE,
|
|
||||||
implantModel: adjustableCatalog.modelCode,
|
|
||||||
implantManufacturer: adjustableCatalog.manufacturer,
|
|
||||||
implantName: adjustableCatalog.name,
|
|
||||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
|
||||||
isAbandoned: true,
|
|
||||||
shuntMode: 'VPS',
|
|
||||||
proximalPunctureAreas: ['额角'],
|
|
||||||
valvePlacementSites: ['耳后'],
|
|
||||||
distalShuntDirection: '腹腔',
|
|
||||||
initialPressure: 130,
|
|
||||||
implantNotes: 'Seed A1 弃用历史设备',
|
|
||||||
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 清理与种子设备关联的历史任务,保证 seed 可重复执行且生命周期夹具稳定。
|
// 清理与种子设备关联的历史任务,保证 seed 可重复执行且生命周期夹具稳定。
|
||||||
|
|||||||
@ -9,6 +9,7 @@ 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';
|
import { DictionariesModule } from './dictionaries/dictionaries.module.js';
|
||||||
|
import { UploadsModule } from './uploads/uploads.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -22,6 +23,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module.js';
|
|||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
DevicesModule,
|
DevicesModule,
|
||||||
DictionariesModule,
|
DictionariesModule,
|
||||||
|
UploadsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -58,19 +58,22 @@ export const MESSAGES = {
|
|||||||
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
|
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
|
||||||
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
|
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
|
||||||
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
|
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
|
||||||
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生账号',
|
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生或组长账号',
|
||||||
},
|
},
|
||||||
|
|
||||||
TASK: {
|
TASK: {
|
||||||
ITEMS_REQUIRED: '任务明细 items 不能为空',
|
ITEMS_REQUIRED: '任务明细 items 不能为空',
|
||||||
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
|
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
|
||||||
|
DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院',
|
||||||
|
ENGINEER_REQUIRED: '接收工程师必选',
|
||||||
ENGINEER_INVALID: '工程师必须为当前医院有效工程师',
|
ENGINEER_INVALID: '工程师必须为当前医院有效工程师',
|
||||||
TASK_NOT_FOUND: '任务不存在或不属于当前医院',
|
TASK_NOT_FOUND: '任务不存在或不属于当前医院',
|
||||||
ACCEPT_ONLY_PENDING: '仅待接收任务可执行接收',
|
ACCEPT_DISABLED: '当前流程不支持工程师接收,请由创建人直接指定接收工程师',
|
||||||
COMPLETE_ONLY_ACCEPTED: '仅已接收任务可执行完成',
|
ACCEPT_ONLY_PENDING: '仅待指派任务可执行接收',
|
||||||
CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消',
|
COMPLETE_ONLY_ACCEPTED: '仅已指派任务可执行完成',
|
||||||
ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收',
|
CANCEL_ONLY_PENDING_ACCEPTED: '仅待指派/已指派任务可取消',
|
||||||
ENGINEER_ONLY_ASSIGNEE: '仅任务接收工程师可完成任务',
|
ENGINEER_ALREADY_ASSIGNED: '任务已指派给其他工程师',
|
||||||
|
ENGINEER_ONLY_ASSIGNEE: '仅任务接收人可完成任务',
|
||||||
CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务',
|
CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务',
|
||||||
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
|
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
|
||||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||||
@ -99,9 +102,7 @@ export const MESSAGES = {
|
|||||||
|
|
||||||
DEVICE: {
|
DEVICE: {
|
||||||
NOT_FOUND: '设备不存在或无权限访问',
|
NOT_FOUND: '设备不存在或无权限访问',
|
||||||
SN_CODE_REQUIRED: 'snCode 不能为空',
|
CURRENT_PRESSURE_INVALID: 'currentPressure 必须是合法挡位标签',
|
||||||
SN_CODE_DUPLICATE: '设备 SN 已存在',
|
|
||||||
CURRENT_PRESSURE_INVALID: 'currentPressure 必须为大于等于 0 的整数',
|
|
||||||
STATUS_INVALID: '设备状态不合法',
|
STATUS_INVALID: '设备状态不合法',
|
||||||
PATIENT_REQUIRED: 'patientId 必填且必须为整数',
|
PATIENT_REQUIRED: 'patientId 必填且必须为整数',
|
||||||
PATIENT_NOT_FOUND: '归属患者不存在',
|
PATIENT_NOT_FOUND: '归属患者不存在',
|
||||||
@ -123,6 +124,17 @@ export const MESSAGES = {
|
|||||||
SYSTEM_ADMIN_ONLY_MAINTAIN: '仅系统管理员可维护字典',
|
SYSTEM_ADMIN_ONLY_MAINTAIN: '仅系统管理员可维护字典',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
UPLOAD: {
|
||||||
|
FILE_REQUIRED: '请先选择要上传的文件',
|
||||||
|
UNSUPPORTED_FILE_TYPE: '仅支持图片、视频、PDF/Office 文档上传',
|
||||||
|
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息,无法上传文件',
|
||||||
|
SYSTEM_ADMIN_HOSPITAL_REQUIRED:
|
||||||
|
'系统管理员上传文件时必须显式指定 hospitalId',
|
||||||
|
INVALID_IMAGE_FILE: '上传的图片无法解析或压缩失败',
|
||||||
|
INVALID_VIDEO_FILE: '上传的视频无法解析或压缩失败',
|
||||||
|
FFMPEG_NOT_AVAILABLE: '服务端缺少视频压缩能力',
|
||||||
|
},
|
||||||
|
|
||||||
ORG: {
|
ORG: {
|
||||||
HOSPITAL_NOT_FOUND: '医院不存在',
|
HOSPITAL_NOT_FOUND: '医院不存在',
|
||||||
DEPARTMENT_NOT_FOUND: '科室不存在',
|
DEPARTMENT_NOT_FOUND: '科室不存在',
|
||||||
|
|||||||
56
src/common/pressure-level.util.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挡位标签标准化:将输入统一整理为可稳定比较和展示的字符串。
|
||||||
|
*/
|
||||||
|
export function normalizePressureLabel(value: unknown, fieldName: string) {
|
||||||
|
const raw =
|
||||||
|
typeof value === 'string' || typeof value === 'number'
|
||||||
|
? String(value).trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
throw new BadRequestException(`${fieldName} 不能为空`);
|
||||||
|
}
|
||||||
|
if (!/^\d+(\.\d+)?$/.test(raw)) {
|
||||||
|
throw new BadRequestException(`${fieldName} 必须是合法挡位标签`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [integerPart, fractionPart = ''] = raw.split('.');
|
||||||
|
const normalizedInteger = integerPart.replace(/^0+(?=\d)/, '') || '0';
|
||||||
|
const normalizedFraction = fractionPart.replace(/0+$/, '');
|
||||||
|
|
||||||
|
return normalizedFraction
|
||||||
|
? `${normalizedInteger}.${normalizedFraction}`
|
||||||
|
: normalizedInteger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挡位标签比较:按数值大小排序,数值相同再按标准字符串比较。
|
||||||
|
*/
|
||||||
|
export function comparePressureLabel(left: string, right: string) {
|
||||||
|
const leftNumber = Number(left);
|
||||||
|
const rightNumber = Number(right);
|
||||||
|
|
||||||
|
if (leftNumber !== rightNumber) {
|
||||||
|
return leftNumber - rightNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.localeCompare(right, 'en');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挡位列表标准化:去重、排序。
|
||||||
|
*/
|
||||||
|
export function normalizePressureLabelList(
|
||||||
|
values: unknown[] | undefined,
|
||||||
|
fieldName: string,
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(values) || values.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set(values.map((value) => normalizePressureLabel(value, fieldName))),
|
||||||
|
).sort(comparePressureLabel);
|
||||||
|
}
|
||||||
@ -55,7 +55,13 @@ export class DepartmentsController {
|
|||||||
* 查询科室列表。
|
* 查询科室列表。
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
@Roles(
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
|
)
|
||||||
@ApiOperation({ summary: '查询科室列表' })
|
@ApiOperation({ summary: '查询科室列表' })
|
||||||
@ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' })
|
@ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' })
|
||||||
findAll(
|
findAll(
|
||||||
@ -69,7 +75,13 @@ export class DepartmentsController {
|
|||||||
* 查询科室详情。
|
* 查询科室详情。
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
@Roles(
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
|
)
|
||||||
@ApiOperation({ summary: '查询科室详情' })
|
@ApiOperation({ summary: '查询科室详情' })
|
||||||
@ApiParam({ name: 'id', description: '科室 ID' })
|
@ApiParam({ name: 'id', description: '科室 ID' })
|
||||||
findOne(
|
findOne(
|
||||||
@ -83,7 +95,7 @@ export class DepartmentsController {
|
|||||||
* 更新科室。
|
* 更新科室。
|
||||||
*/
|
*/
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
@ApiOperation({ summary: '更新科室' })
|
@ApiOperation({ summary: '更新科室' })
|
||||||
update(
|
update(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export class DepartmentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询科室列表:院管限定本院;主任/组长限定本科室。
|
* 查询科室列表:院管限定本院。
|
||||||
*/
|
*/
|
||||||
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
||||||
this.access.assertRole(actor, [
|
this.access.assertRole(actor, [
|
||||||
@ -56,6 +56,7 @@ export class DepartmentsService {
|
|||||||
Role.HOSPITAL_ADMIN,
|
Role.HOSPITAL_ADMIN,
|
||||||
Role.DIRECTOR,
|
Role.DIRECTOR,
|
||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
]);
|
]);
|
||||||
const paging = this.access.resolvePaging(query);
|
const paging = this.access.resolvePaging(query);
|
||||||
const where: Prisma.DepartmentWhereInput = {};
|
const where: Prisma.DepartmentWhereInput = {};
|
||||||
@ -66,7 +67,11 @@ export class DepartmentsService {
|
|||||||
|
|
||||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||||
where.hospitalId = this.access.requireActorHospitalId(actor);
|
where.hospitalId = this.access.requireActorHospitalId(actor);
|
||||||
} else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
|
} else if (
|
||||||
|
actor.role === Role.DIRECTOR ||
|
||||||
|
actor.role === Role.LEADER ||
|
||||||
|
actor.role === Role.DOCTOR
|
||||||
|
) {
|
||||||
where.id = this.access.requireActorDepartmentId(actor);
|
where.id = this.access.requireActorDepartmentId(actor);
|
||||||
} else if (query.hospitalId != null) {
|
} else if (query.hospitalId != null) {
|
||||||
where.hospitalId = this.access.toInt(
|
where.hospitalId = this.access.toInt(
|
||||||
@ -93,7 +98,7 @@ export class DepartmentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询科室详情:院管仅可查看本院;主任/组长仅可查看本科室。
|
* 查询科室详情:院管仅可查看本院。
|
||||||
*/
|
*/
|
||||||
async findOne(actor: ActorContext, id: number) {
|
async findOne(actor: ActorContext, id: number) {
|
||||||
this.access.assertRole(actor, [
|
this.access.assertRole(actor, [
|
||||||
@ -101,6 +106,7 @@ export class DepartmentsService {
|
|||||||
Role.HOSPITAL_ADMIN,
|
Role.HOSPITAL_ADMIN,
|
||||||
Role.DIRECTOR,
|
Role.DIRECTOR,
|
||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
]);
|
]);
|
||||||
const departmentId = this.access.toInt(
|
const departmentId = this.access.toInt(
|
||||||
id,
|
id,
|
||||||
@ -118,18 +124,21 @@ export class DepartmentsService {
|
|||||||
}
|
}
|
||||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||||
this.access.assertHospitalScope(actor, department.hospitalId);
|
this.access.assertHospitalScope(actor, department.hospitalId);
|
||||||
}
|
} else if (
|
||||||
if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
|
actor.role === Role.DIRECTOR ||
|
||||||
|
actor.role === Role.LEADER ||
|
||||||
|
actor.role === Role.DOCTOR
|
||||||
|
) {
|
||||||
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
||||||
if (department.id !== actorDepartmentId) {
|
if (department.id !== actorDepartmentId) {
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return department;
|
return department;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新科室:院管仅可修改本院;主任/组长仅可修改本科室。
|
* 更新科室:院管仅可修改本院。
|
||||||
*/
|
*/
|
||||||
async update(actor: ActorContext, id: number, dto: UpdateDepartmentDto) {
|
async update(actor: ActorContext, id: number, dto: UpdateDepartmentDto) {
|
||||||
const current = await this.findOne(actor, id);
|
const current = await this.findOne(actor, id);
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import { Prisma } from '../generated/prisma/client.js';
|
|||||||
import { DeviceStatus, Role } from '../generated/prisma/enums.js';
|
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 {
|
||||||
|
normalizePressureLabelList,
|
||||||
|
normalizePressureLabel,
|
||||||
|
} from '../common/pressure-level.util.js';
|
||||||
import { PrismaService } from '../prisma.service.js';
|
import { PrismaService } from '../prisma.service.js';
|
||||||
import { CreateImplantCatalogDto } from './dto/create-implant-catalog.dto.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';
|
||||||
@ -133,15 +137,12 @@ export class DevicesService {
|
|||||||
async create(actor: ActorContext, dto: CreateDeviceDto) {
|
async create(actor: ActorContext, dto: CreateDeviceDto) {
|
||||||
this.assertAdmin(actor);
|
this.assertAdmin(actor);
|
||||||
|
|
||||||
const snCode = this.normalizeSnCode(dto.snCode);
|
|
||||||
const patient = await this.resolveWritablePatient(actor, dto.patientId);
|
const patient = await this.resolveWritablePatient(actor, dto.patientId);
|
||||||
await this.assertSnCodeUnique(snCode);
|
|
||||||
|
|
||||||
return this.prisma.device.create({
|
return this.prisma.device.create({
|
||||||
data: {
|
data: {
|
||||||
snCode,
|
|
||||||
// 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。
|
// 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。
|
||||||
currentPressure: 0,
|
currentPressure: '0',
|
||||||
status: dto.status ?? DeviceStatus.ACTIVE,
|
status: dto.status ?? DeviceStatus.ACTIVE,
|
||||||
patientId: patient.id,
|
patientId: patient.id,
|
||||||
},
|
},
|
||||||
@ -150,17 +151,12 @@ export class DevicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新设备:允许修改 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);
|
||||||
|
|
||||||
const data: Prisma.DeviceUpdateInput = {};
|
const data: Prisma.DeviceUpdateInput = {};
|
||||||
if (dto.snCode !== undefined) {
|
|
||||||
const snCode = this.normalizeSnCode(dto.snCode);
|
|
||||||
await this.assertSnCodeUnique(snCode, current.id);
|
|
||||||
data.snCode = snCode;
|
|
||||||
}
|
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
data.status = this.normalizeStatus(dto.status);
|
data.status = this.normalizeStatus(dto.status);
|
||||||
}
|
}
|
||||||
@ -366,12 +362,6 @@ export class DevicesService {
|
|||||||
if (keyword) {
|
if (keyword) {
|
||||||
andConditions.push({
|
andConditions.push({
|
||||||
OR: [
|
OR: [
|
||||||
{
|
|
||||||
snCode: {
|
|
||||||
contains: keyword,
|
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
implantModel: {
|
implantModel: {
|
||||||
contains: keyword,
|
contains: keyword,
|
||||||
@ -590,13 +580,6 @@ export class DevicesService {
|
|||||||
return this.normalizeRequiredString(value, 'modelCode').toUpperCase();
|
return this.normalizeRequiredString(value, 'modelCode').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备 SN 标准化:统一去空白并转大写,避免大小写重复。
|
|
||||||
*/
|
|
||||||
private normalizeSnCode(value: unknown) {
|
|
||||||
return this.normalizeRequiredString(value, 'snCode').toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
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} 必须是字符串`);
|
||||||
@ -622,41 +605,25 @@ export class DevicesService {
|
|||||||
* 挡位列表标准化:去重、排序,并在非可调压目录下自动清空。
|
* 挡位列表标准化:去重、排序,并在非可调压目录下自动清空。
|
||||||
*/
|
*/
|
||||||
private normalizePressureLevels(
|
private normalizePressureLevels(
|
||||||
pressureLevels: number[] | undefined,
|
pressureLevels: unknown[] | undefined,
|
||||||
isPressureAdjustable: boolean,
|
isPressureAdjustable: boolean,
|
||||||
) {
|
) {
|
||||||
if (!isPressureAdjustable) {
|
if (!isPressureAdjustable) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(pressureLevels) || pressureLevels.length === 0) {
|
return normalizePressureLabelList(pressureLevels, 'pressureLevels');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 压力值必须是非负整数。
|
* 当前压力挡位标签标准化。
|
||||||
*/
|
*/
|
||||||
private normalizePressure(value: unknown) {
|
private normalizePressure(value: unknown) {
|
||||||
const parsed = Number(value);
|
try {
|
||||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
return normalizePressureLabel(value, 'currentPressure');
|
||||||
|
} catch {
|
||||||
throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID);
|
throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID);
|
||||||
}
|
}
|
||||||
return parsed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -693,18 +660,4 @@ export class DevicesService {
|
|||||||
}
|
}
|
||||||
return actor.hospitalId;
|
return actor.hospitalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 确保设备 SN 唯一;更新时允许命中自身。
|
|
||||||
*/
|
|
||||||
private async assertSnCodeUnique(snCode: string, selfId?: number) {
|
|
||||||
const existing = await this.prisma.device.findUnique({
|
|
||||||
where: { snCode },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing && existing.id !== selfId) {
|
|
||||||
throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { DeviceStatus } from '../../generated/prisma/enums.js';
|
import { DeviceStatus } from '../../generated/prisma/enums.js';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator';
|
import { IsEnum, IsInt, IsOptional, Min } from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建设备 DTO。
|
* 创建设备 DTO。
|
||||||
*/
|
*/
|
||||||
export class CreateDeviceDto {
|
export class CreateDeviceDto {
|
||||||
@ApiProperty({ description: '设备 SN', example: 'TYT-SN-10001' })
|
|
||||||
@IsString({ message: 'snCode 必须是字符串' })
|
|
||||||
snCode!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '设备状态,默认 ACTIVE',
|
description: '设备状态,默认 ACTIVE',
|
||||||
enum: DeviceStatus,
|
enum: DeviceStatus,
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import {
|
import {
|
||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsInt,
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
|
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
|
||||||
|
|
||||||
@ -38,17 +35,15 @@ export class CreateImplantCatalogDto {
|
|||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '可调压器械的挡位列表,按整数录入',
|
description: '可调压器械的挡位列表,按字符串挡位标签录入',
|
||||||
type: [Number],
|
type: [String],
|
||||||
example: [80, 100, 120, 140],
|
example: ['0.5', '1', '1.5'],
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray({ message: 'pressureLevels 必须是数组' })
|
@IsArray({ message: 'pressureLevels 必须是数组' })
|
||||||
@ArrayMaxSize(30, { message: 'pressureLevels 最多 30 项' })
|
@ArrayMaxSize(30, { message: 'pressureLevels 最多 30 项' })
|
||||||
@Type(() => Number)
|
@IsString({ each: true, message: 'pressureLevels 必须为字符串数组' })
|
||||||
@IsInt({ each: true, message: 'pressureLevels 必须为整数数组' })
|
pressureLevels?: string[];
|
||||||
@Min(0, { each: true, message: 'pressureLevels 必须大于等于 0' })
|
|
||||||
pressureLevels?: number[];
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '是否支持调压,默认 true',
|
description: '是否支持调压,默认 true',
|
||||||
|
|||||||
@ -9,8 +9,9 @@ import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
|||||||
*/
|
*/
|
||||||
export class DeviceQueryDto {
|
export class DeviceQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '关键词(支持设备 SN / 患者姓名 / 患者手机号)',
|
description:
|
||||||
example: 'SN-A',
|
'关键词(支持植入物型号 / 植入物名称 / 患者姓名 / 患者手机号)',
|
||||||
|
example: '脑室',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: 'keyword 必须是字符串' })
|
@IsString({ message: 'keyword 必须是字符串' })
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export class GroupsController {
|
|||||||
* 创建小组。
|
* 创建小组。
|
||||||
*/
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
@ApiOperation({ summary: '创建小组' })
|
@ApiOperation({ summary: '创建小组' })
|
||||||
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateGroupDto) {
|
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateGroupDto) {
|
||||||
return this.groupsService.create(actor, dto);
|
return this.groupsService.create(actor, dto);
|
||||||
@ -51,7 +51,13 @@ export class GroupsController {
|
|||||||
* 查询小组列表。
|
* 查询小组列表。
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
@Roles(
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
|
)
|
||||||
@ApiOperation({ summary: '查询小组列表' })
|
@ApiOperation({ summary: '查询小组列表' })
|
||||||
findAll(
|
findAll(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@ -64,7 +70,13 @@ export class GroupsController {
|
|||||||
* 查询小组详情。
|
* 查询小组详情。
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
@Roles(
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
|
)
|
||||||
@ApiOperation({ summary: '查询小组详情' })
|
@ApiOperation({ summary: '查询小组详情' })
|
||||||
@ApiParam({ name: 'id', description: '小组 ID' })
|
@ApiParam({ name: 'id', description: '小组 ID' })
|
||||||
findOne(
|
findOne(
|
||||||
@ -78,7 +90,7 @@ export class GroupsController {
|
|||||||
* 更新小组。
|
* 更新小组。
|
||||||
*/
|
*/
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
@ApiOperation({ summary: '更新小组' })
|
@ApiOperation({ summary: '更新小组' })
|
||||||
update(
|
update(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@ -92,7 +104,7 @@ export class GroupsController {
|
|||||||
* 删除小组。
|
* 删除小组。
|
||||||
*/
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
@ApiOperation({ summary: '删除小组' })
|
@ApiOperation({ summary: '删除小组' })
|
||||||
remove(
|
remove(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
|
|||||||
@ -26,14 +26,10 @@ export class GroupsService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建小组:系统管理员可跨院;院管仅可在本院;主任仅可在本科室创建。
|
* 创建小组:系统管理员可跨院;院管仅可在本院。
|
||||||
*/
|
*/
|
||||||
async create(actor: ActorContext, dto: CreateGroupDto) {
|
async create(actor: ActorContext, dto: CreateGroupDto) {
|
||||||
this.access.assertRole(actor, [
|
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||||
Role.SYSTEM_ADMIN,
|
|
||||||
Role.HOSPITAL_ADMIN,
|
|
||||||
Role.DIRECTOR,
|
|
||||||
]);
|
|
||||||
const departmentId = this.access.toInt(
|
const departmentId = this.access.toInt(
|
||||||
dto.departmentId,
|
dto.departmentId,
|
||||||
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
|
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
|
||||||
@ -42,12 +38,6 @@ export class GroupsService {
|
|||||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||||
this.access.assertHospitalScope(actor, department.hospitalId);
|
this.access.assertHospitalScope(actor, department.hospitalId);
|
||||||
}
|
}
|
||||||
if (actor.role === Role.DIRECTOR) {
|
|
||||||
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
|
||||||
if (actorDepartmentId !== department.id) {
|
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.group.create({
|
return this.prisma.group.create({
|
||||||
data: {
|
data: {
|
||||||
@ -61,7 +51,7 @@ export class GroupsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询小组列表:院管限定本院;主任限定本科室;组长限定本组。
|
* 查询小组列表:院管限定本院。
|
||||||
*/
|
*/
|
||||||
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
||||||
this.access.assertRole(actor, [
|
this.access.assertRole(actor, [
|
||||||
@ -69,6 +59,7 @@ export class GroupsService {
|
|||||||
Role.HOSPITAL_ADMIN,
|
Role.HOSPITAL_ADMIN,
|
||||||
Role.DIRECTOR,
|
Role.DIRECTOR,
|
||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
]);
|
]);
|
||||||
const paging = this.access.resolvePaging(query);
|
const paging = this.access.resolvePaging(query);
|
||||||
const where: Prisma.GroupWhereInput = {};
|
const where: Prisma.GroupWhereInput = {};
|
||||||
@ -87,10 +78,12 @@ export class GroupsService {
|
|||||||
where.department = {
|
where.department = {
|
||||||
hospitalId: this.access.requireActorHospitalId(actor),
|
hospitalId: this.access.requireActorHospitalId(actor),
|
||||||
};
|
};
|
||||||
} else if (actor.role === Role.DIRECTOR) {
|
} else if (
|
||||||
|
actor.role === Role.DIRECTOR ||
|
||||||
|
actor.role === Role.LEADER ||
|
||||||
|
actor.role === Role.DOCTOR
|
||||||
|
) {
|
||||||
where.departmentId = this.access.requireActorDepartmentId(actor);
|
where.departmentId = this.access.requireActorDepartmentId(actor);
|
||||||
} else if (actor.role === Role.LEADER) {
|
|
||||||
where.id = this.access.requireActorGroupId(actor);
|
|
||||||
} else if (query.hospitalId != null) {
|
} else if (query.hospitalId != null) {
|
||||||
where.department = {
|
where.department = {
|
||||||
hospitalId: this.access.toInt(
|
hospitalId: this.access.toInt(
|
||||||
@ -118,7 +111,7 @@ export class GroupsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询小组详情:院管仅可查看本院;主任仅可查看本科室;组长仅可查看本组。
|
* 查询小组详情:院管仅可查看本院。
|
||||||
*/
|
*/
|
||||||
async findOne(actor: ActorContext, id: number) {
|
async findOne(actor: ActorContext, id: number) {
|
||||||
this.access.assertRole(actor, [
|
this.access.assertRole(actor, [
|
||||||
@ -126,6 +119,7 @@ export class GroupsService {
|
|||||||
Role.HOSPITAL_ADMIN,
|
Role.HOSPITAL_ADMIN,
|
||||||
Role.DIRECTOR,
|
Role.DIRECTOR,
|
||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
]);
|
]);
|
||||||
const groupId = this.access.toInt(id, MESSAGES.ORG.GROUP_ID_REQUIRED);
|
const groupId = this.access.toInt(id, MESSAGES.ORG.GROUP_ID_REQUIRED);
|
||||||
const group = await this.prisma.group.findUnique({
|
const group = await this.prisma.group.findUnique({
|
||||||
@ -140,24 +134,21 @@ export class GroupsService {
|
|||||||
}
|
}
|
||||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||||
this.access.assertHospitalScope(actor, group.department.hospital.id);
|
this.access.assertHospitalScope(actor, group.department.hospital.id);
|
||||||
}
|
} else if (
|
||||||
if (actor.role === Role.DIRECTOR) {
|
actor.role === Role.DIRECTOR ||
|
||||||
|
actor.role === Role.LEADER ||
|
||||||
|
actor.role === Role.DOCTOR
|
||||||
|
) {
|
||||||
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
||||||
if (group.department.id !== actorDepartmentId) {
|
if (group.department.id !== actorDepartmentId) {
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
|
||||||
}
|
|
||||||
}
|
|
||||||
if (actor.role === Role.LEADER) {
|
|
||||||
const actorGroupId = this.access.requireActorGroupId(actor);
|
|
||||||
if (group.id !== actorGroupId) {
|
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新小组:院管仅可修改本院;主任仅可修改本科室;组长仅可修改本组。
|
* 更新小组:院管仅可修改本院。
|
||||||
*/
|
*/
|
||||||
async update(actor: ActorContext, id: number, dto: UpdateGroupDto) {
|
async update(actor: ActorContext, id: number, dto: UpdateGroupDto) {
|
||||||
const current = await this.findOne(actor, id);
|
const current = await this.findOne(actor, id);
|
||||||
@ -181,14 +172,10 @@ export class GroupsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除小组:院管仅可删除本院;主任仅可删除本科室小组。
|
* 删除小组:院管仅可删除本院。
|
||||||
*/
|
*/
|
||||||
async remove(actor: ActorContext, id: number) {
|
async remove(actor: ActorContext, id: number) {
|
||||||
this.access.assertRole(actor, [
|
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||||
Role.SYSTEM_ADMIN,
|
|
||||||
Role.HOSPITAL_ADMIN,
|
|
||||||
Role.DIRECTOR,
|
|
||||||
]);
|
|
||||||
const current = await this.findOne(actor, id);
|
const current = await this.findOne(actor, id);
|
||||||
|
|
||||||
// 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。
|
// 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。
|
||||||
|
|||||||
@ -1,16 +1,23 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
import { HttpExceptionFilter } from './common/http-exception.filter.js';
|
import { HttpExceptionFilter } from './common/http-exception.filter.js';
|
||||||
import { MESSAGES } from './common/messages.js';
|
import { MESSAGES } from './common/messages.js';
|
||||||
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
|
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
|
||||||
|
import { resolveUploadRootDir } from './uploads/upload-path.util.js';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
// 创建应用实例并加载核心模块。
|
// 创建应用实例并加载核心模块。
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
|
mkdirSync(resolveUploadRootDir(), { recursive: true });
|
||||||
|
app.useStaticAssets(resolveUploadRootDir(), {
|
||||||
|
prefix: '/uploads/',
|
||||||
|
});
|
||||||
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
|
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
@ -11,6 +10,7 @@ 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 { normalizePressureLabel } from '../../common/pressure-level.util.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 { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
|
||||||
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
||||||
@ -33,10 +33,10 @@ const IMPLANT_CATALOG_SELECT = {
|
|||||||
const PATIENT_LIST_INCLUDE = {
|
const PATIENT_LIST_INCLUDE = {
|
||||||
hospital: { select: { id: true, name: true } },
|
hospital: { select: { id: true, name: true } },
|
||||||
doctor: { select: { id: true, name: true, role: true } },
|
doctor: { select: { id: true, name: true, role: true } },
|
||||||
|
creator: { select: { id: true, name: true, role: true } },
|
||||||
devices: {
|
devices: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
snCode: true,
|
|
||||||
status: true,
|
status: true,
|
||||||
currentPressure: true,
|
currentPressure: true,
|
||||||
isAbandoned: true,
|
isAbandoned: true,
|
||||||
@ -52,6 +52,7 @@ const PATIENT_LIST_INCLUDE = {
|
|||||||
id: true,
|
id: true,
|
||||||
surgeryDate: true,
|
surgeryDate: true,
|
||||||
surgeryName: true,
|
surgeryName: true,
|
||||||
|
surgeonId: true,
|
||||||
surgeonName: true,
|
surgeonName: true,
|
||||||
},
|
},
|
||||||
orderBy: { surgeryDate: 'desc' },
|
orderBy: { surgeryDate: 'desc' },
|
||||||
@ -76,6 +77,13 @@ const PATIENT_DETAIL_INCLUDE = {
|
|||||||
groupId: true,
|
groupId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
devices: {
|
devices: {
|
||||||
include: {
|
include: {
|
||||||
implantCatalog: {
|
implantCatalog: {
|
||||||
@ -101,6 +109,13 @@ const PATIENT_DETAIL_INCLUDE = {
|
|||||||
},
|
},
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
},
|
},
|
||||||
|
surgeon: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: { surgeryDate: 'desc' },
|
orderBy: { surgeryDate: 'desc' },
|
||||||
},
|
},
|
||||||
@ -212,6 +227,7 @@ export class BPatientsService {
|
|||||||
const patient = await tx.patient.create({
|
const patient = await tx.patient.create({
|
||||||
data: {
|
data: {
|
||||||
name: this.normalizeRequiredString(dto.name, 'name'),
|
name: this.normalizeRequiredString(dto.name, 'name'),
|
||||||
|
creatorId: actor.id,
|
||||||
inpatientNo:
|
inpatientNo:
|
||||||
dto.inpatientNo === undefined
|
dto.inpatientNo === undefined
|
||||||
? undefined
|
? undefined
|
||||||
@ -231,7 +247,9 @@ export class BPatientsService {
|
|||||||
if (dto.initialSurgery) {
|
if (dto.initialSurgery) {
|
||||||
await this.createPatientSurgeryRecord(
|
await this.createPatientSurgeryRecord(
|
||||||
tx,
|
tx,
|
||||||
|
actor,
|
||||||
patient.id,
|
patient.id,
|
||||||
|
patient.doctorId,
|
||||||
dto.initialSurgery,
|
dto.initialSurgery,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -255,7 +273,9 @@ export class BPatientsService {
|
|||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const createdSurgery = await this.createPatientSurgeryRecord(
|
const createdSurgery = await this.createPatientSurgeryRecord(
|
||||||
tx,
|
tx,
|
||||||
|
actor,
|
||||||
patient.id,
|
patient.id,
|
||||||
|
patient.doctorId,
|
||||||
dto,
|
dto,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -440,6 +460,7 @@ export class BPatientsService {
|
|||||||
where: { id: normalizedDoctorId },
|
where: { id: normalizedDoctorId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
name: true,
|
||||||
role: true,
|
role: true,
|
||||||
hospitalId: true,
|
hospitalId: true,
|
||||||
departmentId: true,
|
departmentId: true,
|
||||||
@ -553,7 +574,9 @@ export class BPatientsService {
|
|||||||
*/
|
*/
|
||||||
private async createPatientSurgeryRecord(
|
private async createPatientSurgeryRecord(
|
||||||
prisma: PrismaExecutor,
|
prisma: PrismaExecutor,
|
||||||
|
actor: ActorContext,
|
||||||
patientId: number,
|
patientId: number,
|
||||||
|
patientDoctorId: number,
|
||||||
dto: CreatePatientSurgeryDto,
|
dto: CreatePatientSurgeryDto,
|
||||||
) {
|
) {
|
||||||
if (!Array.isArray(dto.devices) || dto.devices.length === 0) {
|
if (!Array.isArray(dto.devices) || dto.devices.length === 0) {
|
||||||
@ -571,13 +594,14 @@ export class BPatientsService {
|
|||||||
new Set(dto.abandonedDeviceIds ?? []),
|
new Set(dto.abandonedDeviceIds ?? []),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [catalogMap, latestSurgery] = await Promise.all([
|
const [catalogMap, latestSurgery, surgeon] = await Promise.all([
|
||||||
this.resolveImplantCatalogMap(prisma, catalogIds),
|
this.resolveImplantCatalogMap(prisma, catalogIds),
|
||||||
prisma.patientSurgery.findFirst({
|
prisma.patientSurgery.findFirst({
|
||||||
where: { patientId },
|
where: { patientId },
|
||||||
orderBy: { surgeryDate: 'desc' },
|
orderBy: { surgeryDate: 'desc' },
|
||||||
select: { surgeryDate: true },
|
select: { surgeryDate: true },
|
||||||
}),
|
}),
|
||||||
|
this.resolveWritableDoctor(actor, patientDoctorId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (abandonedDeviceIds.length > 0) {
|
if (abandonedDeviceIds.length > 0) {
|
||||||
@ -596,7 +620,7 @@ export class BPatientsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceDrafts = dto.devices.map((device, index) => {
|
const deviceDrafts = dto.devices.map((device) => {
|
||||||
const catalog = catalogMap.get(device.implantCatalogId);
|
const catalog = catalogMap.get(device.implantCatalogId);
|
||||||
if (!catalog) {
|
if (!catalog) {
|
||||||
throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND);
|
throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND);
|
||||||
@ -607,7 +631,7 @@ export class BPatientsService {
|
|||||||
? null
|
? null
|
||||||
: this.assertPressureLevelAllowed(
|
: this.assertPressureLevelAllowed(
|
||||||
catalog,
|
catalog,
|
||||||
this.normalizeNonNegativeInteger(
|
this.normalizePressureLevel(
|
||||||
device.initialPressure,
|
device.initialPressure,
|
||||||
'initialPressure',
|
'initialPressure',
|
||||||
),
|
),
|
||||||
@ -615,18 +639,17 @@ export class BPatientsService {
|
|||||||
const fallbackPressureLevel =
|
const fallbackPressureLevel =
|
||||||
catalog.isPressureAdjustable && catalog.pressureLevels.length > 0
|
catalog.isPressureAdjustable && catalog.pressureLevels.length > 0
|
||||||
? catalog.pressureLevels[0]
|
? catalog.pressureLevels[0]
|
||||||
: 0;
|
: '0';
|
||||||
const currentPressure = catalog.isPressureAdjustable
|
const currentPressure = catalog.isPressureAdjustable
|
||||||
? this.assertPressureLevelAllowed(
|
? this.assertPressureLevelAllowed(
|
||||||
catalog,
|
catalog,
|
||||||
initialPressure ?? fallbackPressureLevel,
|
initialPressure ?? fallbackPressureLevel,
|
||||||
)
|
)
|
||||||
: 0;
|
: '0';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
patient: { connect: { id: patientId } },
|
patient: { connect: { id: patientId } },
|
||||||
implantCatalog: { connect: { id: catalog.id } },
|
implantCatalog: { connect: { id: catalog.id } },
|
||||||
snCode: this.resolveDeviceSnCode(device.snCode, patientId, index),
|
|
||||||
currentPressure,
|
currentPressure,
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
implantModel: catalog.modelCode,
|
implantModel: catalog.modelCode,
|
||||||
@ -662,11 +685,6 @@ export class BPatientsService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.assertSnCodesUnique(
|
|
||||||
prisma,
|
|
||||||
deviceDrafts.map((device) => device.snCode),
|
|
||||||
);
|
|
||||||
|
|
||||||
const surgery = await prisma.patientSurgery.create({
|
const surgery = await prisma.patientSurgery.create({
|
||||||
data: {
|
data: {
|
||||||
patientId,
|
patientId,
|
||||||
@ -675,10 +693,8 @@ export class BPatientsService {
|
|||||||
dto.surgeryName,
|
dto.surgeryName,
|
||||||
'surgeryName',
|
'surgeryName',
|
||||||
),
|
),
|
||||||
surgeonName: this.normalizeRequiredString(
|
surgeonId: surgeon.id,
|
||||||
dto.surgeonName,
|
surgeonName: surgeon.name,
|
||||||
'surgeonName',
|
|
||||||
),
|
|
||||||
preOpPressure:
|
preOpPressure:
|
||||||
dto.preOpPressure == null
|
dto.preOpPressure == null
|
||||||
? null
|
? null
|
||||||
@ -765,9 +781,9 @@ export class BPatientsService {
|
|||||||
private assertPressureLevelAllowed(
|
private assertPressureLevelAllowed(
|
||||||
catalog: {
|
catalog: {
|
||||||
isPressureAdjustable: boolean;
|
isPressureAdjustable: boolean;
|
||||||
pressureLevels: number[];
|
pressureLevels: string[];
|
||||||
},
|
},
|
||||||
pressure: number,
|
pressure: string,
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
catalog.isPressureAdjustable &&
|
catalog.isPressureAdjustable &&
|
||||||
@ -898,6 +914,10 @@ export class BPatientsService {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizePressureLevel(value: unknown, fieldName: string) {
|
||||||
|
return normalizePressureLabel(value, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeStringArray(value: unknown, fieldName: string) {
|
private normalizeStringArray(value: unknown, fieldName: string) {
|
||||||
if (!Array.isArray(value) || value.length === 0) {
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
throw new BadRequestException(`${fieldName} 必须为非空数组`);
|
throw new BadRequestException(`${fieldName} 必须为非空数组`);
|
||||||
@ -927,39 +947,6 @@ export class BPatientsService {
|
|||||||
})) as Prisma.InputJsonArray;
|
})) 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) {
|
private toInt(value: unknown, fieldName: string) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
|||||||
@ -110,11 +110,10 @@ export class CPatientsService {
|
|||||||
},
|
},
|
||||||
devices: surgery.devices.map((device) => ({
|
devices: surgery.devices.map((device) => ({
|
||||||
id: this.toJsonNumber(device.id),
|
id: this.toJsonNumber(device.id),
|
||||||
snCode: device.snCode,
|
|
||||||
status: device.status,
|
status: device.status,
|
||||||
isAbandoned: device.isAbandoned,
|
isAbandoned: device.isAbandoned,
|
||||||
currentPressure: this.toJsonNumber(device.currentPressure),
|
currentPressure: device.currentPressure,
|
||||||
initialPressure: this.toJsonNumber(device.initialPressure),
|
initialPressure: device.initialPressure,
|
||||||
implantModel: device.implantModel,
|
implantModel: device.implantModel,
|
||||||
implantManufacturer: device.implantManufacturer,
|
implantManufacturer: device.implantManufacturer,
|
||||||
implantName: device.implantName,
|
implantName: device.implantName,
|
||||||
@ -149,10 +148,9 @@ export class CPatientsService {
|
|||||||
},
|
},
|
||||||
device: {
|
device: {
|
||||||
id: this.toJsonNumber(device.id),
|
id: this.toJsonNumber(device.id),
|
||||||
snCode: device.snCode,
|
|
||||||
status: device.status,
|
status: device.status,
|
||||||
isAbandoned: device.isAbandoned,
|
isAbandoned: device.isAbandoned,
|
||||||
currentPressure: this.toJsonNumber(device.currentPressure),
|
currentPressure: device.currentPressure,
|
||||||
implantModel: device.implantModel,
|
implantModel: device.implantModel,
|
||||||
implantManufacturer: device.implantManufacturer,
|
implantManufacturer: device.implantManufacturer,
|
||||||
implantName: device.implantName,
|
implantName: device.implantName,
|
||||||
@ -172,8 +170,8 @@ export class CPatientsService {
|
|||||||
},
|
},
|
||||||
taskItem: {
|
taskItem: {
|
||||||
id: this.toJsonNumber(taskItem.id),
|
id: this.toJsonNumber(taskItem.id),
|
||||||
oldPressure: this.toJsonNumber(taskItem.oldPressure),
|
oldPressure: taskItem.oldPressure,
|
||||||
targetPressure: this.toJsonNumber(taskItem.targetPressure),
|
targetPressure: taskItem.targetPressure,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -32,13 +32,6 @@ export class CreatePatientSurgeryDto {
|
|||||||
@IsString({ message: 'surgeryName 必须是字符串' })
|
@IsString({ message: 'surgeryName 必须是字符串' })
|
||||||
surgeryName!: string;
|
surgeryName!: string;
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '主刀医生',
|
|
||||||
example: '张主任',
|
|
||||||
})
|
|
||||||
@IsString({ message: 'surgeonName 必须是字符串' })
|
|
||||||
surgeonName!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '术前测压,可为空',
|
description: '术前测压,可为空',
|
||||||
example: 22,
|
example: 22,
|
||||||
|
|||||||
@ -23,14 +23,6 @@ export class CreateSurgeryDeviceDto {
|
|||||||
@Min(1, { message: 'implantCatalogId 必须大于 0' })
|
@Min(1, { message: 'implantCatalogId 必须大于 0' })
|
||||||
implantCatalogId!: number;
|
implantCatalogId!: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '设备 SN,可不传;不传时系统自动生成',
|
|
||||||
example: 'TYT-SHUNT-001',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: 'snCode 必须是字符串' })
|
|
||||||
snCode?: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: '分流方式',
|
description: '分流方式',
|
||||||
example: 'VPS',
|
example: 'VPS',
|
||||||
@ -68,14 +60,12 @@ export class CreateSurgeryDeviceDto {
|
|||||||
distalShuntDirection!: string;
|
distalShuntDirection!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '初始压力,可为空',
|
description: '初始压力挡位,可为空',
|
||||||
example: 120,
|
example: '1.5',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@IsString({ message: 'initialPressure 必须是字符串' })
|
||||||
@IsInt({ message: 'initialPressure 必须是整数' })
|
initialPressure?: string;
|
||||||
@Min(0, { message: 'initialPressure 必须大于等于 0' })
|
|
||||||
initialPressure?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '植入物备注',
|
description: '植入物备注',
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
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';
|
||||||
@ -11,6 +16,8 @@ import { PublishTaskDto } from '../dto/publish-task.dto.js';
|
|||||||
import { AcceptTaskDto } from '../dto/accept-task.dto.js';
|
import { AcceptTaskDto } from '../dto/accept-task.dto.js';
|
||||||
import { CompleteTaskDto } from '../dto/complete-task.dto.js';
|
import { CompleteTaskDto } from '../dto/complete-task.dto.js';
|
||||||
import { CancelTaskDto } from '../dto/cancel-task.dto.js';
|
import { CancelTaskDto } from '../dto/cancel-task.dto.js';
|
||||||
|
import { TaskRecordQueryDto } from '../dto/task-record-query.dto.js';
|
||||||
|
import { AssignableEngineerQueryDto } from '../dto/assignable-engineer-query.dto.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* B 端任务控制器:封装调压任务状态流转接口。
|
* B 端任务控制器:封装调压任务状态流转接口。
|
||||||
@ -23,21 +30,78 @@ export class BTasksController {
|
|||||||
constructor(private readonly taskService: TaskService) {}
|
constructor(private readonly taskService: TaskService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 医生/主任/组长发布调压任务。
|
* 查询当前角色可指定的接收工程师列表。
|
||||||
|
*/
|
||||||
|
@Get('engineers')
|
||||||
|
@Roles(
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DOCTOR,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
)
|
||||||
|
@ApiOperation({ summary: '查询可选接收工程师列表' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'hospitalId',
|
||||||
|
required: false,
|
||||||
|
description: '系统管理员可按医院筛选',
|
||||||
|
})
|
||||||
|
findAssignableEngineers(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Query() query: AssignableEngineerQueryDto,
|
||||||
|
) {
|
||||||
|
return this.taskService.findAssignableEngineers(actor, query.hospitalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前角色可见的调压记录列表。
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@Roles(
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DOCTOR,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
Role.ENGINEER,
|
||||||
|
)
|
||||||
|
@ApiOperation({ summary: '查询调压记录列表' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'hospitalId',
|
||||||
|
required: false,
|
||||||
|
description: '系统管理员可按医院筛选',
|
||||||
|
})
|
||||||
|
findRecords(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Query() query: TaskRecordQueryDto,
|
||||||
|
) {
|
||||||
|
return this.taskService.findTaskRecords(actor, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统管理员/医院管理员/医生/主任/组长发布调压任务。
|
||||||
*/
|
*/
|
||||||
@Post('publish')
|
@Post('publish')
|
||||||
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER)
|
@Roles(
|
||||||
@ApiOperation({ summary: '发布任务(DOCTOR/DIRECTOR/LEADER)' })
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DOCTOR,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '发布任务(SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER)',
|
||||||
|
})
|
||||||
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
|
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
|
||||||
return this.taskService.publishTask(actor, dto);
|
return this.taskService.publishTask(actor, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工程师接收调压任务。
|
* 工程师接收调压任务(当前流程已停用)。
|
||||||
*/
|
*/
|
||||||
@Post('accept')
|
@Post('accept')
|
||||||
@Roles(Role.ENGINEER)
|
@Roles(Role.ENGINEER)
|
||||||
@ApiOperation({ summary: '接收任务(ENGINEER)' })
|
@ApiOperation({ summary: '接收任务(已停用)' })
|
||||||
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
|
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
|
||||||
return this.taskService.acceptTask(actor, dto);
|
return this.taskService.acceptTask(actor, dto);
|
||||||
}
|
}
|
||||||
@ -53,11 +117,19 @@ export class BTasksController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 医生/主任/组长取消调压任务(仅任务创建者)。
|
* 系统管理员/医院管理员/医生/主任/组长取消调压任务(仅任务创建者)。
|
||||||
*/
|
*/
|
||||||
@Post('cancel')
|
@Post('cancel')
|
||||||
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER)
|
@Roles(
|
||||||
@ApiOperation({ summary: '取消任务(DOCTOR/DIRECTOR/LEADER)' })
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DOCTOR,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '取消任务(SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER)',
|
||||||
|
})
|
||||||
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
|
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
|
||||||
return this.taskService.cancelTask(actor, dto);
|
return this.taskService.cancelTask(actor, dto);
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/tasks/dto/assignable-engineer-query.dto.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, Min } from 'class-validator';
|
||||||
|
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可指派工程师查询 DTO:系统管理员可显式传医院范围。
|
||||||
|
*/
|
||||||
|
export class AssignableEngineerQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '医院 ID,仅系统管理员可选传',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||||
|
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||||
|
hospitalId?: number;
|
||||||
|
}
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
|
||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsString,
|
||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@ -20,23 +19,20 @@ export class PublishTaskItemDto {
|
|||||||
@Min(1, { message: 'deviceId 必须大于 0' })
|
@Min(1, { message: 'deviceId 必须大于 0' })
|
||||||
deviceId!: number;
|
deviceId!: number;
|
||||||
|
|
||||||
@ApiProperty({ description: '目标压力值', example: 120 })
|
@ApiProperty({ description: '目标挡位标签', example: '1.5' })
|
||||||
@Type(() => Number)
|
@IsString({ message: 'targetPressure 必须是字符串' })
|
||||||
@IsInt({ message: 'targetPressure 必须是整数' })
|
targetPressure!: string;
|
||||||
targetPressure!: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布任务 DTO。
|
* 发布任务 DTO。
|
||||||
*/
|
*/
|
||||||
export class PublishTaskDto {
|
export class PublishTaskDto {
|
||||||
@ApiPropertyOptional({ description: '指定工程师 ID(可选)', example: 2 })
|
@ApiProperty({ description: '接收工程师 ID', example: 2 })
|
||||||
@IsOptional()
|
|
||||||
@EmptyStringToUndefined()
|
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt({ message: 'engineerId 必须是整数' })
|
@IsInt({ message: 'engineerId 必须是整数' })
|
||||||
@Min(1, { message: 'engineerId 必须大于 0' })
|
@Min(1, { message: 'engineerId 必须大于 0' })
|
||||||
engineerId?: number;
|
engineerId!: number;
|
||||||
|
|
||||||
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
|
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
|
||||||
@IsArray({ message: 'items 必须是数组' })
|
@IsArray({ message: 'items 必须是数组' })
|
||||||
|
|||||||
63
src/tasks/dto/task-record-query.dto.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||||
|
import { TaskStatus } from '../../generated/prisma/enums.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调压记录查询 DTO:用于后台任务记录页筛选。
|
||||||
|
*/
|
||||||
|
export class TaskRecordQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '关键词(支持患者姓名/住院号/手机号/植入物名称/植入物型号)',
|
||||||
|
example: '张三',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'keyword 必须是字符串' })
|
||||||
|
keyword?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '任务状态',
|
||||||
|
enum: TaskStatus,
|
||||||
|
example: TaskStatus.PENDING,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TaskStatus, { message: 'status 枚举值不合法' })
|
||||||
|
status?: TaskStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '医院 ID,仅系统管理员可选传',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||||
|
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||||
|
hospitalId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '页码(默认 1)',
|
||||||
|
example: 1,
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'page 必须是整数' })
|
||||||
|
@Min(1, { message: 'page 最小为 1' })
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '每页数量(默认 20,最大 100)',
|
||||||
|
example: 20,
|
||||||
|
default: 20,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'pageSize 必须是整数' })
|
||||||
|
@Min(1, { message: 'pageSize 最小为 1' })
|
||||||
|
@Max(100, { message: 'pageSize 最大为 100' })
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { Prisma } from '../generated/prisma/client.js';
|
||||||
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
|
import { DeviceStatus, Role, TaskStatus } 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';
|
||||||
@ -13,7 +14,9 @@ import { PublishTaskDto } from './dto/publish-task.dto.js';
|
|||||||
import { AcceptTaskDto } from './dto/accept-task.dto.js';
|
import { AcceptTaskDto } from './dto/accept-task.dto.js';
|
||||||
import { CompleteTaskDto } from './dto/complete-task.dto.js';
|
import { CompleteTaskDto } from './dto/complete-task.dto.js';
|
||||||
import { CancelTaskDto } from './dto/cancel-task.dto.js';
|
import { CancelTaskDto } from './dto/cancel-task.dto.js';
|
||||||
|
import { TaskRecordQueryDto } from './dto/task-record-query.dto.js';
|
||||||
import { MESSAGES } from '../common/messages.js';
|
import { MESSAGES } from '../common/messages.js';
|
||||||
|
import { normalizePressureLabel } from '../common/pressure-level.util.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任务服务:封装调压任务状态机、院内隔离与事件发布逻辑。
|
* 任务服务:封装调压任务状态机、院内隔离与事件发布逻辑。
|
||||||
@ -26,11 +29,162 @@ export class TaskService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布任务:医生/主任/组长创建主任务与明细,状态初始化为 PENDING。
|
* 查询当前角色可指定的接收工程师列表。
|
||||||
|
*/
|
||||||
|
async findAssignableEngineers(
|
||||||
|
actor: ActorContext,
|
||||||
|
requestedHospitalId?: number,
|
||||||
|
) {
|
||||||
|
this.assertRole(actor, [
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DOCTOR,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hospitalId = this.resolveAssignableHospitalId(
|
||||||
|
actor,
|
||||||
|
requestedHospitalId,
|
||||||
|
);
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
role: Role.ENGINEER,
|
||||||
|
hospitalId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
phone: true,
|
||||||
|
hospitalId: true,
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前角色可见的调压记录列表。
|
||||||
|
*/
|
||||||
|
async findTaskRecords(actor: ActorContext, query: TaskRecordQueryDto) {
|
||||||
|
this.assertRole(actor, [
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DOCTOR,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
Role.ENGINEER,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hospitalId = this.resolveListHospitalId(actor, query.hospitalId);
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
const where = this.buildTaskRecordWhere(query, hospitalId);
|
||||||
|
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
this.prisma.taskItem.count({ where }),
|
||||||
|
this.prisma.taskItem.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
oldPressure: true,
|
||||||
|
targetPressure: true,
|
||||||
|
task: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
hospital: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
engineer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
device: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
currentPressure: true,
|
||||||
|
implantModel: true,
|
||||||
|
implantManufacturer: true,
|
||||||
|
implantName: true,
|
||||||
|
patient: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
inpatientNo: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
surgery: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
surgeryName: true,
|
||||||
|
surgeryDate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
oldPressure: item.oldPressure,
|
||||||
|
targetPressure: item.targetPressure,
|
||||||
|
currentPressure: item.device.currentPressure,
|
||||||
|
taskId: item.task.id,
|
||||||
|
status: item.task.status,
|
||||||
|
createdAt: item.task.createdAt,
|
||||||
|
hospital: item.task.hospital,
|
||||||
|
creator: item.task.creator,
|
||||||
|
engineer: item.task.engineer,
|
||||||
|
patient: item.device.patient,
|
||||||
|
surgery: item.device.surgery,
|
||||||
|
device: {
|
||||||
|
id: item.device.id,
|
||||||
|
implantModel: item.device.implantModel,
|
||||||
|
implantManufacturer: item.device.implantManufacturer,
|
||||||
|
implantName: item.device.implantName,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布任务:管理员或临床角色创建主任务与明细,并直接指定接收工程师。
|
||||||
*/
|
*/
|
||||||
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
|
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
|
||||||
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
|
this.assertRole(actor, [
|
||||||
const hospitalId = this.requireHospitalId(actor);
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DOCTOR,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!Array.isArray(dto.items) || dto.items.length === 0) {
|
if (!Array.isArray(dto.items) || dto.items.length === 0) {
|
||||||
throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED);
|
throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED);
|
||||||
@ -42,27 +196,32 @@ export class TaskService {
|
|||||||
if (!Number.isInteger(item.deviceId)) {
|
if (!Number.isInteger(item.deviceId)) {
|
||||||
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
|
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(item.targetPressure)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`targetPressure 非法: ${item.targetPressure}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return item.deviceId;
|
return item.deviceId;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scopedHospitalId = this.resolveScopedHospitalId(actor);
|
||||||
const devices = await this.prisma.device.findMany({
|
const devices = await this.prisma.device.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: deviceIds },
|
id: { in: deviceIds },
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
isAbandoned: false,
|
isAbandoned: false,
|
||||||
isPressureAdjustable: true,
|
isPressureAdjustable: true,
|
||||||
patient: { hospitalId },
|
patient: scopedHospitalId
|
||||||
|
? {
|
||||||
|
hospitalId: scopedHospitalId,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
currentPressure: true,
|
currentPressure: true,
|
||||||
|
patient: {
|
||||||
|
select: {
|
||||||
|
hospitalId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
implantCatalog: {
|
implantCatalog: {
|
||||||
select: {
|
select: {
|
||||||
pressureLevels: true,
|
pressureLevels: true,
|
||||||
@ -75,7 +234,11 @@ export class TaskService {
|
|||||||
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
|
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.engineerId != null) {
|
const hospitalId = this.resolveTaskHospitalId(
|
||||||
|
actor,
|
||||||
|
devices.map((device) => device.patient.hospitalId),
|
||||||
|
);
|
||||||
|
|
||||||
const engineer = await this.prisma.user.findFirst({
|
const engineer = await this.prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: dto.engineerId,
|
id: dto.engineerId,
|
||||||
@ -87,7 +250,6 @@ export class TaskService {
|
|||||||
if (!engineer) {
|
if (!engineer) {
|
||||||
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
|
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
||||||
@ -102,25 +264,30 @@ export class TaskService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
dto.items.forEach((item) => {
|
dto.items.forEach((item) => {
|
||||||
|
const normalizedTargetPressure = this.normalizeTargetPressure(
|
||||||
|
item.targetPressure,
|
||||||
|
);
|
||||||
const pressureLevels = pressureLevelsByDeviceId.get(item.deviceId) ?? [];
|
const pressureLevels = pressureLevelsByDeviceId.get(item.deviceId) ?? [];
|
||||||
if (
|
if (
|
||||||
pressureLevels.length > 0 &&
|
pressureLevels.length > 0 &&
|
||||||
!pressureLevels.includes(item.targetPressure)
|
!pressureLevels.includes(normalizedTargetPressure)
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID);
|
throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item.targetPressure = normalizedTargetPressure;
|
||||||
});
|
});
|
||||||
|
|
||||||
const task = await this.prisma.task.create({
|
const task = await this.prisma.task.create({
|
||||||
data: {
|
data: {
|
||||||
status: TaskStatus.PENDING,
|
status: TaskStatus.ACCEPTED,
|
||||||
creatorId: actor.id,
|
creatorId: actor.id,
|
||||||
engineerId: dto.engineerId ?? null,
|
engineerId: engineer.id,
|
||||||
hospitalId,
|
hospitalId,
|
||||||
items: {
|
items: {
|
||||||
create: dto.items.map((item) => ({
|
create: dto.items.map((item) => ({
|
||||||
deviceId: item.deviceId,
|
deviceId: item.deviceId,
|
||||||
oldPressure: pressureByDeviceId.get(item.deviceId) ?? 0,
|
oldPressure: pressureByDeviceId.get(item.deviceId) ?? '0',
|
||||||
targetPressure: item.targetPressure,
|
targetPressure: item.targetPressure,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@ -139,68 +306,10 @@ export class TaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 接收任务:工程师将任务从 PENDING 流转到 ACCEPTED。
|
* 接收任务:当前流程已停用,统一改为发布时直接指定接收工程师。
|
||||||
*/
|
*/
|
||||||
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
|
async acceptTask(_actor: ActorContext, _dto: AcceptTaskDto) {
|
||||||
this.assertRole(actor, [Role.ENGINEER]);
|
throw new ForbiddenException(MESSAGES.TASK.ACCEPT_DISABLED);
|
||||||
const hospitalId = this.requireHospitalId(actor);
|
|
||||||
|
|
||||||
const task = await this.prisma.task.findFirst({
|
|
||||||
where: {
|
|
||||||
id: dto.taskId,
|
|
||||||
hospitalId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
status: true,
|
|
||||||
hospitalId: true,
|
|
||||||
engineerId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!task) {
|
|
||||||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
|
||||||
}
|
|
||||||
if (task.status !== TaskStatus.PENDING) {
|
|
||||||
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
|
|
||||||
}
|
|
||||||
if (task.engineerId != null && task.engineerId !== actor.id) {
|
|
||||||
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accepted = await this.prisma.task.updateMany({
|
|
||||||
where: {
|
|
||||||
id: task.id,
|
|
||||||
hospitalId,
|
|
||||||
status: TaskStatus.PENDING,
|
|
||||||
OR: [{ engineerId: null }, { engineerId: actor.id }],
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: TaskStatus.ACCEPTED,
|
|
||||||
engineerId: actor.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (accepted.count !== 1) {
|
|
||||||
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedTask = await this.prisma.task.findUnique({
|
|
||||||
where: { id: task.id },
|
|
||||||
include: { items: true },
|
|
||||||
});
|
|
||||||
if (!updatedTask) {
|
|
||||||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.eventEmitter.emitAsync('task.accepted', {
|
|
||||||
taskId: updatedTask.id,
|
|
||||||
hospitalId: updatedTask.hospitalId,
|
|
||||||
actorId: actor.id,
|
|
||||||
status: updatedTask.status,
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -263,13 +372,19 @@ export class TaskService {
|
|||||||
* 取消任务:任务创建者可将 PENDING/ACCEPTED 任务取消。
|
* 取消任务:任务创建者可将 PENDING/ACCEPTED 任务取消。
|
||||||
*/
|
*/
|
||||||
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
||||||
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
|
this.assertRole(actor, [
|
||||||
const hospitalId = this.requireHospitalId(actor);
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DOCTOR,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
]);
|
||||||
|
const scopedHospitalId = this.resolveScopedHospitalId(actor);
|
||||||
|
|
||||||
const task = await this.prisma.task.findFirst({
|
const task = await this.prisma.task.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: dto.taskId,
|
id: dto.taskId,
|
||||||
hospitalId,
|
hospitalId: scopedHospitalId ?? undefined,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@ -320,7 +435,154 @@ export class TaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验并返回 hospitalId(B 端强依赖租户隔离)。
|
* 返回角色可见的医院范围。系统管理员可不绑定医院,按目标设备自动归院。
|
||||||
|
*/
|
||||||
|
private resolveScopedHospitalId(actor: ActorContext): number | null {
|
||||||
|
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||||
|
return actor.hospitalId ?? null;
|
||||||
|
}
|
||||||
|
if (!actor.hospitalId) {
|
||||||
|
throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED);
|
||||||
|
}
|
||||||
|
return actor.hospitalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析本次任务归属医院,确保同一批设备不跨院。
|
||||||
|
*/
|
||||||
|
private resolveTaskHospitalId(
|
||||||
|
actor: ActorContext,
|
||||||
|
hospitalIds: number[],
|
||||||
|
): number {
|
||||||
|
const uniqueHospitalIds = Array.from(
|
||||||
|
new Set(hospitalIds.filter((hospitalId) => Number.isInteger(hospitalId))),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uniqueHospitalIds.length !== 1) {
|
||||||
|
throw new BadRequestException(MESSAGES.TASK.DEVICE_MULTI_HOSPITAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hospitalId] = uniqueHospitalIds;
|
||||||
|
if (actor.hospitalId && actor.hospitalId !== hospitalId) {
|
||||||
|
throw new ForbiddenException(MESSAGES.TASK.DEVICE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hospitalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析工程师指派范围。系统管理员可显式指定医院,其余角色固定本院。
|
||||||
|
*/
|
||||||
|
private resolveAssignableHospitalId(
|
||||||
|
actor: ActorContext,
|
||||||
|
requestedHospitalId?: number,
|
||||||
|
) {
|
||||||
|
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||||
|
if (requestedHospitalId !== undefined) {
|
||||||
|
return requestedHospitalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.requireHospitalId(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.requireHospitalId(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表查询的医院范围解析。系统管理员可按查询条件切院,其余角色固定本院。
|
||||||
|
*/
|
||||||
|
private resolveListHospitalId(
|
||||||
|
actor: ActorContext,
|
||||||
|
requestedHospitalId?: number,
|
||||||
|
) {
|
||||||
|
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||||
|
return requestedHospitalId ?? actor.hospitalId ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.requireHospitalId(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造调压记录查询条件。
|
||||||
|
*/
|
||||||
|
private buildTaskRecordWhere(
|
||||||
|
query: TaskRecordQueryDto,
|
||||||
|
hospitalId?: number,
|
||||||
|
): Prisma.TaskItemWhereInput {
|
||||||
|
const keyword = query.keyword?.trim();
|
||||||
|
|
||||||
|
const where: Prisma.TaskItemWhereInput = {
|
||||||
|
task: {
|
||||||
|
hospitalId,
|
||||||
|
status: query.status,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
where.OR = [
|
||||||
|
{
|
||||||
|
device: {
|
||||||
|
patient: {
|
||||||
|
name: {
|
||||||
|
contains: keyword,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
device: {
|
||||||
|
patient: {
|
||||||
|
inpatientNo: {
|
||||||
|
contains: keyword,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
device: {
|
||||||
|
patient: {
|
||||||
|
phone: {
|
||||||
|
contains: keyword,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
device: {
|
||||||
|
implantName: {
|
||||||
|
contains: keyword,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
device: {
|
||||||
|
implantModel: {
|
||||||
|
contains: keyword,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调压目标挡位标准化。
|
||||||
|
*/
|
||||||
|
private normalizeTargetPressure(value: unknown) {
|
||||||
|
return normalizePressureLabel(value, 'targetPressure');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工程师侧任务流转仍要求明确的院内身份。
|
||||||
*/
|
*/
|
||||||
private requireHospitalId(actor: ActorContext): number {
|
private requireHospitalId(actor: ActorContext): number {
|
||||||
if (!actor.hospitalId) {
|
if (!actor.hospitalId) {
|
||||||
|
|||||||
133
src/uploads/b-uploads/b-uploads.controller.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
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 { MESSAGES } from '../../common/messages.js';
|
||||||
|
import { UploadAssetQueryDto } from '../dto/upload-asset-query.dto.js';
|
||||||
|
import { ensureUploadDirectories, resolveUploadTempDir } from '../upload-path.util.js';
|
||||||
|
import { UploadsService } from '../uploads.service.js';
|
||||||
|
import { diskStorage } from 'multer';
|
||||||
|
import { extname } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const MAX_UPLOAD_SIZE = 1024 * 1024 * 200;
|
||||||
|
|
||||||
|
function isAllowedMimeType(mimeType: string) {
|
||||||
|
return (
|
||||||
|
mimeType.startsWith('image/') ||
|
||||||
|
mimeType.startsWith('video/') ||
|
||||||
|
[
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
].includes(mimeType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('上传资产(B端)')
|
||||||
|
@ApiBearerAuth('bearer')
|
||||||
|
@Controller('b/uploads')
|
||||||
|
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||||
|
export class BUploadsController {
|
||||||
|
constructor(private readonly uploadsService: UploadsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传图片/视频/文件。
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@Roles(
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
|
)
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
|
ensureUploadDirectories();
|
||||||
|
cb(null, resolveUploadTempDir());
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
cb(null, `${randomUUID()}${extname(file.originalname)}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: { fileSize: MAX_UPLOAD_SIZE },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (!isAllowedMimeType(file.mimetype)) {
|
||||||
|
cb(
|
||||||
|
new BadRequestException(MESSAGES.UPLOAD.UNSUPPORTED_FILE_TYPE),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiOperation({ summary: '上传图片/视频/文件' })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'binary',
|
||||||
|
},
|
||||||
|
hospitalId: {
|
||||||
|
type: 'integer',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['file'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
create(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
@Body('hospitalId') hospitalId?: string,
|
||||||
|
) {
|
||||||
|
const requestedHospitalId =
|
||||||
|
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
|
||||||
|
|
||||||
|
return this.uploadsService.createUpload(actor, file, requestedHospitalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询图库/视频库/文件库。
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
|
@ApiOperation({ summary: '查询上传资产列表' })
|
||||||
|
findAll(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Query() query: UploadAssetQueryDto,
|
||||||
|
) {
|
||||||
|
return this.uploadsService.findAll(actor, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/uploads/dto/upload-asset-query.dto.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||||
|
import { UploadAssetType } from '../../generated/prisma/enums.js';
|
||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传资产查询 DTO:用于图库/视频库分页筛选。
|
||||||
|
*/
|
||||||
|
export class UploadAssetQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '关键词(按原始文件名模糊匹配)',
|
||||||
|
example: 'ct',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'keyword 必须是字符串' })
|
||||||
|
keyword?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '资产类型',
|
||||||
|
enum: UploadAssetType,
|
||||||
|
example: UploadAssetType.IMAGE,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(UploadAssetType, { message: 'type 枚举值不合法' })
|
||||||
|
type?: UploadAssetType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '医院 ID(SYSTEM_ADMIN 可选)', example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||||
|
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||||
|
hospitalId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '页码', example: 1, default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'page 必须是整数' })
|
||||||
|
@Min(1, { message: 'page 最小为 1' })
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '每页数量',
|
||||||
|
example: 20,
|
||||||
|
default: 20,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'pageSize 必须是整数' })
|
||||||
|
@Min(1, { message: 'pageSize 最小为 1' })
|
||||||
|
@Max(100, { message: 'pageSize 最大为 100' })
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
18
src/uploads/upload-path.util.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传目录工具:统一管理公开文件目录与临时目录。
|
||||||
|
*/
|
||||||
|
export function resolveUploadRootDir() {
|
||||||
|
return join(process.cwd(), 'storage', 'uploads');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUploadTempDir() {
|
||||||
|
return join(process.cwd(), 'storage', 'tmp-uploads');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureUploadDirectories() {
|
||||||
|
mkdirSync(resolveUploadRootDir(), { recursive: true });
|
||||||
|
mkdirSync(resolveUploadTempDir(), { recursive: true });
|
||||||
|
}
|
||||||
12
src/uploads/uploads.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../prisma.module.js';
|
||||||
|
import { BUploadsController } from './b-uploads/b-uploads.controller.js';
|
||||||
|
import { UploadsService } from './uploads.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [BUploadsController],
|
||||||
|
providers: [UploadsService],
|
||||||
|
exports: [UploadsService],
|
||||||
|
})
|
||||||
|
export class UploadsModule {}
|
||||||
18
src/uploads/uploads.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UploadsService } from './uploads.service';
|
||||||
|
|
||||||
|
describe('UploadsService', () => {
|
||||||
|
let service: UploadsService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [UploadsService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<UploadsService>(UploadsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
383
src/uploads/uploads.service.ts
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { access, mkdir, rename, stat, unlink } from 'node:fs/promises';
|
||||||
|
import { extname, join } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import ffmpegPath from 'ffmpeg-static';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { Prisma } from '../generated/prisma/client.js';
|
||||||
|
import { Role, UploadAssetType } 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 { UploadAssetQueryDto } from './dto/upload-asset-query.dto.js';
|
||||||
|
import {
|
||||||
|
ensureUploadDirectories,
|
||||||
|
resolveUploadRootDir,
|
||||||
|
resolveUploadTempDir,
|
||||||
|
} from './upload-path.util.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UploadsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建上传资产记录并落盘到公开目录。
|
||||||
|
*/
|
||||||
|
async createUpload(
|
||||||
|
actor: ActorContext,
|
||||||
|
file: Express.Multer.File | undefined,
|
||||||
|
requestedHospitalId?: number,
|
||||||
|
) {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException(MESSAGES.UPLOAD.FILE_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hospitalId = await this.resolveHospitalScope(actor, requestedHospitalId);
|
||||||
|
const type = this.detectAssetType(file.mimetype);
|
||||||
|
ensureUploadDirectories();
|
||||||
|
const prepared = await this.prepareStoredFile(file, type);
|
||||||
|
const date = new Date();
|
||||||
|
const relativeDir = join(
|
||||||
|
`${date.getFullYear()}`,
|
||||||
|
`${date.getMonth() + 1}`.padStart(2, '0'),
|
||||||
|
`${date.getDate()}`.padStart(2, '0'),
|
||||||
|
);
|
||||||
|
const absoluteDir = join(resolveUploadRootDir(), relativeDir);
|
||||||
|
await mkdir(absoluteDir, { recursive: true });
|
||||||
|
|
||||||
|
const finalFileName = await this.buildFinalFileName(
|
||||||
|
absoluteDir,
|
||||||
|
file.originalname,
|
||||||
|
prepared.outputExtension,
|
||||||
|
date,
|
||||||
|
);
|
||||||
|
const relativePath = join(relativeDir, finalFileName);
|
||||||
|
const absolutePath = join(resolveUploadRootDir(), relativePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rename(prepared.tempPath, absolutePath);
|
||||||
|
if (prepared.tempPath !== file.path) {
|
||||||
|
await this.safeUnlink(file.path);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await this.safeUnlink(file.path);
|
||||||
|
await this.safeUnlink(prepared.tempPath);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/${relativePath.replace(/\\/g, '/')}`.replace(/^\/+/, '/uploads/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.uploadAsset.create({
|
||||||
|
data: {
|
||||||
|
hospitalId,
|
||||||
|
creatorId: actor.id,
|
||||||
|
type,
|
||||||
|
originalName: file.originalname,
|
||||||
|
fileName: finalFileName,
|
||||||
|
storagePath: relativePath.replace(/\\/g, '/'),
|
||||||
|
url,
|
||||||
|
mimeType: prepared.mimeType,
|
||||||
|
fileSize: prepared.fileSize,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
hospital: { select: { id: true, name: true } },
|
||||||
|
creator: { select: { id: true, name: true, role: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await this.safeUnlink(absolutePath);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询上传资产列表:按医院作用域隔离。
|
||||||
|
*/
|
||||||
|
async findAll(actor: ActorContext, query: UploadAssetQueryDto) {
|
||||||
|
const page = query.page && query.page > 0 ? query.page : 1;
|
||||||
|
const pageSize =
|
||||||
|
query.pageSize && query.pageSize > 0 && query.pageSize <= 100
|
||||||
|
? query.pageSize
|
||||||
|
: 20;
|
||||||
|
|
||||||
|
const where: Prisma.UploadAssetWhereInput = {};
|
||||||
|
const scopedHospitalId = this.resolveReadableHospitalScope(actor, query.hospitalId);
|
||||||
|
if (scopedHospitalId != null) {
|
||||||
|
where.hospitalId = scopedHospitalId;
|
||||||
|
}
|
||||||
|
if (query.type) {
|
||||||
|
where.type = query.type;
|
||||||
|
}
|
||||||
|
if (query.keyword?.trim()) {
|
||||||
|
where.originalName = {
|
||||||
|
contains: query.keyword.trim(),
|
||||||
|
mode: 'insensitive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [total, list] = await this.prisma.$transaction([
|
||||||
|
this.prisma.uploadAsset.count({ where }),
|
||||||
|
this.prisma.uploadAsset.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
hospital: { select: { id: true, name: true } },
|
||||||
|
creator: { select: { id: true, name: true, role: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { total, page, pageSize, list };
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectAssetType(mimeType: string) {
|
||||||
|
if (mimeType.startsWith('image/')) {
|
||||||
|
return UploadAssetType.IMAGE;
|
||||||
|
}
|
||||||
|
if (mimeType.startsWith('video/')) {
|
||||||
|
return UploadAssetType.VIDEO;
|
||||||
|
}
|
||||||
|
return UploadAssetType.FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveHospitalScope(
|
||||||
|
actor: ActorContext,
|
||||||
|
requestedHospitalId?: number,
|
||||||
|
) {
|
||||||
|
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||||
|
if (
|
||||||
|
requestedHospitalId == null ||
|
||||||
|
!Number.isInteger(requestedHospitalId) ||
|
||||||
|
requestedHospitalId <= 0
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
MESSAGES.UPLOAD.SYSTEM_ADMIN_HOSPITAL_REQUIRED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hospital = await this.prisma.hospital.findUnique({
|
||||||
|
where: { id: requestedHospitalId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!hospital) {
|
||||||
|
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return hospital.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actor.hospitalId) {
|
||||||
|
throw new BadRequestException(MESSAGES.UPLOAD.ACTOR_HOSPITAL_REQUIRED);
|
||||||
|
}
|
||||||
|
return actor.hospitalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareStoredFile(
|
||||||
|
file: Express.Multer.File,
|
||||||
|
type: UploadAssetType,
|
||||||
|
) {
|
||||||
|
const baseName = randomUUID();
|
||||||
|
|
||||||
|
if (type === UploadAssetType.IMAGE) {
|
||||||
|
const tempPath = join(resolveUploadTempDir(), `${baseName}.webp`);
|
||||||
|
try {
|
||||||
|
await sharp(file.path)
|
||||||
|
.rotate()
|
||||||
|
.resize({
|
||||||
|
width: 2560,
|
||||||
|
height: 2560,
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.webp({
|
||||||
|
quality: 82,
|
||||||
|
effort: 4,
|
||||||
|
})
|
||||||
|
.toFile(tempPath);
|
||||||
|
} catch {
|
||||||
|
await this.safeUnlink(tempPath);
|
||||||
|
throw new BadRequestException(MESSAGES.UPLOAD.INVALID_IMAGE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInfo = await stat(tempPath);
|
||||||
|
return {
|
||||||
|
tempPath,
|
||||||
|
outputExtension: '.webp',
|
||||||
|
mimeType: 'image/webp',
|
||||||
|
fileSize: fileInfo.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === UploadAssetType.VIDEO) {
|
||||||
|
const tempPath = join(resolveUploadTempDir(), `${baseName}.mp4`);
|
||||||
|
try {
|
||||||
|
await this.compressVideo(file.path, tempPath);
|
||||||
|
} catch (error) {
|
||||||
|
await this.safeUnlink(tempPath);
|
||||||
|
if (error instanceof BadRequestException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new BadRequestException(MESSAGES.UPLOAD.INVALID_VIDEO_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInfo = await stat(tempPath);
|
||||||
|
return {
|
||||||
|
tempPath,
|
||||||
|
outputExtension: '.mp4',
|
||||||
|
mimeType: 'video/mp4',
|
||||||
|
fileSize: fileInfo.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tempPath: file.path,
|
||||||
|
outputExtension: extname(file.originalname),
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
fileSize: file.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildFinalFileName(
|
||||||
|
absoluteDir: string,
|
||||||
|
originalName: string,
|
||||||
|
outputExtension: string,
|
||||||
|
date: Date,
|
||||||
|
) {
|
||||||
|
const timestamp = this.formatDateToSecond(date);
|
||||||
|
const originalBaseName =
|
||||||
|
this.normalizeOriginalBaseName(originalName) || 'upload-file';
|
||||||
|
const extension = outputExtension || extname(originalName) || '';
|
||||||
|
const baseFileName = `${timestamp}-${originalBaseName}`;
|
||||||
|
let candidate = `${baseFileName}${extension}`;
|
||||||
|
let duplicateIndex = 1;
|
||||||
|
|
||||||
|
while (await this.fileExists(join(absoluteDir, candidate))) {
|
||||||
|
candidate = `${baseFileName}-${duplicateIndex}${extension}`;
|
||||||
|
duplicateIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeOriginalBaseName(originalName: string) {
|
||||||
|
const rawBaseName = originalName.replace(/\.[^.]+$/, '');
|
||||||
|
const sanitized = rawBaseName
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/-+/g, '-');
|
||||||
|
|
||||||
|
return sanitized || 'upload-file';
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDateToSecond(date: Date) {
|
||||||
|
const year = String(date.getFullYear());
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}${month}${day}${hours}${minutes}${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fileExists(path: string) {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async compressVideo(inputPath: string, outputPath: string) {
|
||||||
|
const command = ffmpegPath as unknown as string | null;
|
||||||
|
if (!command) {
|
||||||
|
throw new InternalServerErrorException(MESSAGES.UPLOAD.FFMPEG_NOT_AVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-y',
|
||||||
|
'-i',
|
||||||
|
inputPath,
|
||||||
|
'-map',
|
||||||
|
'0:v:0',
|
||||||
|
'-map',
|
||||||
|
'0:a?',
|
||||||
|
'-vf',
|
||||||
|
"scale='if(gt(iw,ih),min(1280,iw),-2)':'if(gt(iw,ih),-2,min(1280,ih))'",
|
||||||
|
'-c:v',
|
||||||
|
'libx264',
|
||||||
|
'-preset',
|
||||||
|
'veryfast',
|
||||||
|
'-crf',
|
||||||
|
'29',
|
||||||
|
'-pix_fmt',
|
||||||
|
'yuv420p',
|
||||||
|
'-movflags',
|
||||||
|
'+faststart',
|
||||||
|
'-c:a',
|
||||||
|
'aac',
|
||||||
|
'-b:a',
|
||||||
|
'128k',
|
||||||
|
outputPath,
|
||||||
|
];
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(command, args);
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
child.stderr?.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(
|
||||||
|
new BadRequestException(
|
||||||
|
stderr.trim() || MESSAGES.UPLOAD.INVALID_VIDEO_FILE,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveReadableHospitalScope(
|
||||||
|
actor: ActorContext,
|
||||||
|
requestedHospitalId?: number,
|
||||||
|
) {
|
||||||
|
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||||
|
return requestedHospitalId && requestedHospitalId > 0
|
||||||
|
? requestedHospitalId
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actor.hospitalId) {
|
||||||
|
throw new BadRequestException(MESSAGES.UPLOAD.ACTOR_HOSPITAL_REQUIRED);
|
||||||
|
}
|
||||||
|
return actor.hospitalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async safeUnlink(path: string | undefined) {
|
||||||
|
if (!path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await unlink(path);
|
||||||
|
} catch {
|
||||||
|
// 临时文件清理失败不影响主流程,避免吞掉原始错误。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,7 +38,7 @@ export class UsersController {
|
|||||||
* 创建用户。
|
* 创建用户。
|
||||||
*/
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
@ApiOperation({ summary: '创建用户' })
|
@ApiOperation({ summary: '创建用户' })
|
||||||
create(
|
create(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@ -61,7 +61,7 @@ export class UsersController {
|
|||||||
* 查询用户详情。
|
* 查询用户详情。
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
@Roles(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(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
|
findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
|
||||||
@ -72,7 +72,7 @@ export class UsersController {
|
|||||||
* 更新用户。
|
* 更新用户。
|
||||||
*/
|
*/
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
@ApiOperation({ summary: '更新用户' })
|
@ApiOperation({ summary: '更新用户' })
|
||||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||||
update(
|
update(
|
||||||
@ -87,7 +87,7 @@ export class UsersController {
|
|||||||
* 删除用户。
|
* 删除用户。
|
||||||
*/
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.DIRECTOR)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
@ApiOperation({ summary: '删除用户' })
|
@ApiOperation({ summary: '删除用户' })
|
||||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||||
remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
|
remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
|
||||||
|
|||||||
@ -30,6 +30,9 @@ const SAFE_USER_SELECT = {
|
|||||||
groupId: true,
|
groupId: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const;
|
||||||
|
const LEADER_VISIBLE_ROLES = [Role.DOCTOR] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@ -202,15 +205,18 @@ export class UsersService {
|
|||||||
actor.hospitalId,
|
actor.hospitalId,
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
||||||
);
|
);
|
||||||
} else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
|
} else if (actor.role === Role.DIRECTOR) {
|
||||||
where.hospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
where.departmentId = this.requireActorScopeInt(
|
where.departmentId = this.requireActorScopeInt(
|
||||||
actor.departmentId,
|
actor.departmentId,
|
||||||
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
||||||
);
|
);
|
||||||
|
where.role = { in: [...DIRECTOR_VISIBLE_ROLES] };
|
||||||
|
} else if (actor.role === Role.LEADER) {
|
||||||
|
where.groupId = this.requireActorScopeInt(
|
||||||
|
actor.groupId,
|
||||||
|
MESSAGES.ORG.ACTOR_GROUP_REQUIRED,
|
||||||
|
);
|
||||||
|
where.role = { in: [...LEADER_VISIBLE_ROLES] };
|
||||||
} else if (actor.role !== Role.SYSTEM_ADMIN) {
|
} else if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||||
}
|
}
|
||||||
@ -682,38 +688,9 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||||
if (actor.role !== Role.DIRECTOR) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 科室主任仅允许创建本科室医生。
|
|
||||||
if (targetRole !== Role.DOCTOR) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
const actorDepartmentId = this.requireActorScopeInt(
|
|
||||||
actor.departmentId,
|
|
||||||
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hospitalId != null && hospitalId !== actorHospitalId) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
if (departmentId != null && departmentId !== actorDepartmentId) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hospitalId: actorHospitalId,
|
|
||||||
departmentId: actorDepartmentId,
|
|
||||||
groupId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
targetRole === Role.SYSTEM_ADMIN ||
|
targetRole === Role.SYSTEM_ADMIN ||
|
||||||
targetRole === Role.HOSPITAL_ADMIN
|
targetRole === Role.HOSPITAL_ADMIN
|
||||||
@ -740,7 +717,7 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取权限:系统管理员可读全量;院管可读本院;主任可读本科室医生。
|
* 读取权限:系统管理员可读全量;院管可读本院。
|
||||||
*/
|
*/
|
||||||
private assertUserReadable(
|
private assertUserReadable(
|
||||||
actor: ActorContext,
|
actor: ActorContext,
|
||||||
@ -749,6 +726,7 @@ export class UsersService {
|
|||||||
role: Role;
|
role: Role;
|
||||||
hospitalId: number | null;
|
hospitalId: number | null;
|
||||||
departmentId: number | null;
|
departmentId: number | null;
|
||||||
|
groupId: number | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||||
@ -769,30 +747,36 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (actor.role === Role.DIRECTOR) {
|
if (actor.role === Role.DIRECTOR) {
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
const actorDepartmentId = this.requireActorScopeInt(
|
const actorDepartmentId = this.requireActorScopeInt(
|
||||||
actor.departmentId,
|
actor.departmentId,
|
||||||
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
target.role === Role.DOCTOR &&
|
target.departmentId === actorDepartmentId &&
|
||||||
target.hospitalId === actorHospitalId &&
|
(DIRECTOR_VISIBLE_ROLES as readonly Role[]).includes(target.role)
|
||||||
target.departmentId === actorDepartmentId
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
if (actor.role === Role.LEADER) {
|
||||||
|
const actorGroupId = this.requireActorScopeInt(
|
||||||
|
actor.groupId,
|
||||||
|
MESSAGES.ORG.ACTOR_GROUP_REQUIRED,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
target.groupId === actorGroupId &&
|
||||||
|
(LEADER_VISIBLE_ROLES as readonly Role[]).includes(target.role)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 写权限:院管可写本院非管理员账号;主任仅可写本科室医生。
|
* 写权限:院管可写本院非管理员账号。
|
||||||
*/
|
*/
|
||||||
private assertUserWritable(
|
private assertUserWritable(
|
||||||
actor: ActorContext,
|
actor: ActorContext,
|
||||||
@ -807,25 +791,6 @@ export class UsersService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actor.role === Role.DIRECTOR) {
|
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
const actorDepartmentId = this.requireActorScopeInt(
|
|
||||||
actor.departmentId,
|
|
||||||
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
target.role !== Role.DOCTOR ||
|
|
||||||
target.hospitalId !== actorHospitalId ||
|
|
||||||
target.departmentId !== actorDepartmentId
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||||
}
|
}
|
||||||
@ -857,10 +822,6 @@ export class UsersService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actor.role === Role.DIRECTOR && nextRole !== Role.DOCTOR) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
actor.role === Role.HOSPITAL_ADMIN &&
|
actor.role === Role.HOSPITAL_ADMIN &&
|
||||||
(nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN)
|
(nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN)
|
||||||
@ -878,17 +839,6 @@ export class UsersService {
|
|||||||
actor: ActorContext,
|
actor: ActorContext,
|
||||||
hospitalId: number | null,
|
hospitalId: number | null,
|
||||||
) {
|
) {
|
||||||
if (actor.role === Role.DIRECTOR) {
|
|
||||||
const actorHospitalId = this.requireActorScopeInt(
|
|
||||||
actor.hospitalId,
|
|
||||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
|
||||||
);
|
|
||||||
if (hospitalId !== actorHospitalId) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -911,17 +861,9 @@ export class UsersService {
|
|||||||
actor: ActorContext,
|
actor: ActorContext,
|
||||||
departmentId: number | null,
|
departmentId: number | null,
|
||||||
) {
|
) {
|
||||||
if (actor.role !== Role.DIRECTOR) {
|
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actorDepartmentId = this.requireActorScopeInt(
|
|
||||||
actor.departmentId,
|
|
||||||
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
|
||||||
);
|
|
||||||
if (departmentId !== actorDepartmentId) {
|
|
||||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
BIN
storage/tmp-uploads/0bdc181b-5a5f-4911-89ca-d5c56bee0626.png
Normal file
|
After Width: | Height: | Size: 137 B |
BIN
storage/tmp-uploads/1f5bd907-8f7f-44e8-b1ed-499f6c5fd604.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
storage/tmp-uploads/23530285-2dce-47e6-bb9a-9b0a5a5a9e4d.png
Normal file
|
After Width: | Height: | Size: 137 B |
@ -0,0 +1 @@
|
|||||||
|
upload-SYSTEM_ADMIN
|
||||||
BIN
storage/tmp-uploads/59692c02-df26-4da0-bffe-0142feb634e0.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
storage/tmp-uploads/6bb38cd8-3ccf-4d43-8114-32759924c246.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
storage/tmp-uploads/a789898a-215a-4ab6-b8dc-5bab6d040b90.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
storage/tmp-uploads/b5c90100-1466-47f7-b765-063e05b0b32c.png
Normal file
|
After Width: | Height: | Size: 137 B |
BIN
storage/tmp-uploads/be5cd629-4d00-4542-82b0-b1b43ce693fe.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
storage/tmp-uploads/ce5177bc-3dfd-4874-b672-4bd90865841a.png
Normal file
|
After Width: | Height: | Size: 137 B |
@ -0,0 +1 @@
|
|||||||
|
upload-SYSTEM_ADMIN
|
||||||
@ -0,0 +1 @@
|
|||||||
|
fake-image-content
|
||||||
@ -0,0 +1 @@
|
|||||||
|
fake-image-content
|
||||||
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
BIN
storage/uploads/2026/03/20/20260320031813-ct-image.webp
Normal file
|
After Width: | Height: | Size: 72 B |
BIN
storage/uploads/2026/03/20/20260320031813-ct-video.mp4
Normal file
BIN
storage/uploads/2026/03/20/20260320031813-director.webp
Normal file
|
After Width: | Height: | Size: 72 B |
BIN
storage/uploads/2026/03/20/20260320031813-hospital_admin.webp
Normal file
|
After Width: | Height: | Size: 72 B |
BIN
storage/uploads/2026/03/20/20260320031813-seed-image.webp
Normal file
|
After Width: | Height: | Size: 72 B |
BIN
storage/uploads/2026/03/20/20260320031814-doctor.webp
Normal file
|
After Width: | Height: | Size: 72 B |
BIN
storage/uploads/2026/03/20/20260320031814-leader.webp
Normal file
|
After Width: | Height: | Size: 72 B |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 72 B |
@ -0,0 +1 @@
|
|||||||
|
upload-HOSPITAL_ADMIN
|
||||||
@ -0,0 +1 @@
|
|||||||
|
upload-DIRECTOR
|
||||||
|
After Width: | Height: | Size: 72 B |
@ -0,0 +1 @@
|
|||||||
|
upload-LEADER
|
||||||
|
After Width: | Height: | Size: 72 B |
@ -0,0 +1 @@
|
|||||||
|
fake-video-content
|
||||||
@ -0,0 +1 @@
|
|||||||
|
upload-HOSPITAL_ADMIN
|
||||||
@ -0,0 +1 @@
|
|||||||
|
upload-LEADER
|
||||||
|
After Width: | Height: | Size: 72 B |
@ -0,0 +1 @@
|
|||||||
|
fake-image-content
|
||||||
|
After Width: | Height: | Size: 72 B |
@ -0,0 +1 @@
|
|||||||
|
upload-DOCTOR
|
||||||
@ -0,0 +1 @@
|
|||||||
|
upload-DIRECTOR
|
||||||
@ -0,0 +1 @@
|
|||||||
|
fake-image-content
|
||||||
@ -0,0 +1 @@
|
|||||||
|
fake-video-content
|
||||||
@ -0,0 +1 @@
|
|||||||
|
upload-DOCTOR
|
||||||
|
After Width: | Height: | Size: 72 B |
@ -5,13 +5,23 @@ import {
|
|||||||
type E2ERole,
|
type E2ERole,
|
||||||
E2E_SEED_CREDENTIALS,
|
E2E_SEED_CREDENTIALS,
|
||||||
} from '../fixtures/e2e-roles.js';
|
} from '../fixtures/e2e-roles.js';
|
||||||
|
import type { E2ESeedFixtures } from './e2e-fixtures.helper.js';
|
||||||
import { expectSuccessEnvelope } from './e2e-http.helper.js';
|
import { expectSuccessEnvelope } from './e2e-http.helper.js';
|
||||||
|
|
||||||
export type E2EAccessTokenMap = Record<E2ERole, string>;
|
export type E2EAccessTokenMap = Record<E2ERole, string>;
|
||||||
|
|
||||||
|
function resolveRoleHospitalId(role: E2ERole, fixtures: E2ESeedFixtures) {
|
||||||
|
if (role === 'SYSTEM_ADMIN') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixtures.hospitalAId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loginAsRole(
|
export async function loginAsRole(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
role: E2ERole,
|
role: E2ERole,
|
||||||
|
fixtures: E2ESeedFixtures,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const credential = E2E_SEED_CREDENTIALS[role];
|
const credential = E2E_SEED_CREDENTIALS[role];
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
@ -19,9 +29,9 @@ export async function loginAsRole(
|
|||||||
password: credential.password,
|
password: credential.password,
|
||||||
role: credential.role,
|
role: credential.role,
|
||||||
};
|
};
|
||||||
|
const hospitalId = resolveRoleHospitalId(role, fixtures);
|
||||||
if (credential.hospitalId != null) {
|
if (hospitalId != null) {
|
||||||
payload.hospitalId = credential.hospitalId;
|
payload.hospitalId = hospitalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
@ -36,10 +46,11 @@ export async function loginAsRole(
|
|||||||
|
|
||||||
export async function loginAllRoles(
|
export async function loginAllRoles(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
|
fixtures: E2ESeedFixtures,
|
||||||
): Promise<E2EAccessTokenMap> {
|
): Promise<E2EAccessTokenMap> {
|
||||||
const tokenEntries = await Promise.all(
|
const tokenEntries = await Promise.all(
|
||||||
E2E_ROLE_LIST.map(
|
E2E_ROLE_LIST.map(
|
||||||
async (role) => [role, await loginAsRole(app, role)] as const,
|
async (role) => [role, await loginAsRole(app, role, fixtures)] as const,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { PrismaService } from '../../../src/prisma.service.js';
|
|||||||
import { loginAllRoles, type E2EAccessTokenMap } from './e2e-auth.helper.js';
|
import { loginAllRoles, type E2EAccessTokenMap } from './e2e-auth.helper.js';
|
||||||
import { createE2eApp } from './e2e-app.helper.js';
|
import { createE2eApp } from './e2e-app.helper.js';
|
||||||
import {
|
import {
|
||||||
loadSeedFixtures,
|
ensureE2EFixtures,
|
||||||
type E2ESeedFixtures,
|
type E2ESeedFixtures,
|
||||||
} from './e2e-fixtures.helper.js';
|
} from './e2e-fixtures.helper.js';
|
||||||
|
|
||||||
@ -17,8 +17,8 @@ export interface E2EContext {
|
|||||||
export async function createE2EContext(): Promise<E2EContext> {
|
export async function createE2EContext(): Promise<E2EContext> {
|
||||||
const app = await createE2eApp();
|
const app = await createE2eApp();
|
||||||
const prisma = app.get(PrismaService);
|
const prisma = app.get(PrismaService);
|
||||||
const fixtures = await loadSeedFixtures(prisma);
|
const fixtures = await ensureE2EFixtures(app, prisma);
|
||||||
const tokens = await loginAllRoles(app);
|
const tokens = await loginAllRoles(app, fixtures);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
app,
|
app,
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { Role } from '../../../src/generated/prisma/enums.js';
|
||||||
import { PrismaService } from '../../../src/prisma.service.js';
|
import { PrismaService } from '../../../src/prisma.service.js';
|
||||||
|
import {
|
||||||
|
E2E_SEED_CREDENTIALS,
|
||||||
|
E2E_SEED_PASSWORD,
|
||||||
|
} from '../fixtures/e2e-roles.js';
|
||||||
|
import { expectSuccessEnvelope } from './e2e-http.helper.js';
|
||||||
|
|
||||||
export interface E2ESeedFixtures {
|
export interface E2ESeedFixtures {
|
||||||
hospitalAId: number;
|
hospitalAId: number;
|
||||||
@ -10,6 +18,10 @@ export interface E2ESeedFixtures {
|
|||||||
groupA1Id: number;
|
groupA1Id: number;
|
||||||
groupA2Id: number;
|
groupA2Id: number;
|
||||||
groupB1Id: number;
|
groupB1Id: number;
|
||||||
|
catalogs: {
|
||||||
|
adjustableValveId: number;
|
||||||
|
highPressureValveId: number;
|
||||||
|
};
|
||||||
users: {
|
users: {
|
||||||
systemAdminId: number;
|
systemAdminId: number;
|
||||||
hospitalAdminAId: number;
|
hospitalAdminAId: number;
|
||||||
@ -44,6 +56,610 @@ interface SeedUserScope {
|
|||||||
groupId: number | null;
|
groupId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OPEN_IDS = {
|
||||||
|
systemAdmin: 'seed-system-admin-openid',
|
||||||
|
hospitalAdminA: 'seed-hospital-admin-a-openid',
|
||||||
|
hospitalAdminB: 'seed-hospital-admin-b-openid',
|
||||||
|
directorA: 'seed-director-a-openid',
|
||||||
|
leaderA: 'seed-leader-a-openid',
|
||||||
|
doctorA: 'seed-doctor-a-openid',
|
||||||
|
doctorA2: 'seed-doctor-a2-openid',
|
||||||
|
doctorA3: 'seed-doctor-a3-openid',
|
||||||
|
doctorB: 'seed-doctor-b-openid',
|
||||||
|
engineerA: 'seed-engineer-a-openid',
|
||||||
|
engineerB: 'seed-engineer-b-openid',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const FIXTURE_NAMES = {
|
||||||
|
hospitalA: 'Seed Hospital A',
|
||||||
|
hospitalB: 'Seed Hospital B',
|
||||||
|
departmentA1: 'Neurosurgery-A1',
|
||||||
|
departmentA2: 'Cardiology-A2',
|
||||||
|
departmentB1: 'Neurosurgery-B1',
|
||||||
|
groupA1: 'Shift-A1',
|
||||||
|
groupA2: 'Shift-A2',
|
||||||
|
groupB1: 'Shift-B1',
|
||||||
|
adjustableCatalog: 'SEED-ADJUSTABLE-VALVE',
|
||||||
|
highPressureCatalog: 'SEED-HIGH-PRESSURE-VALVE',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const EXTRA_PHONES = {
|
||||||
|
hospitalAdminB: '13800001101',
|
||||||
|
doctorA2: '13800001204',
|
||||||
|
doctorA3: '13800001304',
|
||||||
|
doctorB: '13800001104',
|
||||||
|
engineerB: '13800001105',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SHARED_PATIENT_IDENTITY = {
|
||||||
|
phone: '13800002001',
|
||||||
|
idCard: '110101199001010011',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export async function ensureE2EFixtures(
|
||||||
|
app: INestApplication,
|
||||||
|
prisma: PrismaService,
|
||||||
|
): Promise<E2ESeedFixtures> {
|
||||||
|
const existingSystemAdmin = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
phone: E2E_SEED_CREDENTIALS[Role.SYSTEM_ADMIN].phone,
|
||||||
|
role: Role.SYSTEM_ADMIN,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSystemAdmin) {
|
||||||
|
await bootstrapFixturesViaApi(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadSeedFixtures(prisma);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapFixturesViaApi(app: INestApplication) {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
|
||||||
|
await createSystemAdmin(server);
|
||||||
|
const systemAdminToken = await loginByCredential(server, {
|
||||||
|
phone: E2E_SEED_CREDENTIALS[Role.SYSTEM_ADMIN].phone,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.SYSTEM_ADMIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hospitalA = await createWithToken(
|
||||||
|
server,
|
||||||
|
systemAdminToken,
|
||||||
|
'/b/organization/hospitals',
|
||||||
|
{ name: FIXTURE_NAMES.hospitalA },
|
||||||
|
);
|
||||||
|
const hospitalB = await createWithToken(
|
||||||
|
server,
|
||||||
|
systemAdminToken,
|
||||||
|
'/b/organization/hospitals',
|
||||||
|
{ name: FIXTURE_NAMES.hospitalB },
|
||||||
|
);
|
||||||
|
|
||||||
|
const hospitalAdminA = await createWithToken(server, systemAdminToken, '/users', {
|
||||||
|
name: 'Seed Hospital Admin A',
|
||||||
|
phone: E2E_SEED_CREDENTIALS[Role.HOSPITAL_ADMIN].phone,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.HOSPITAL_ADMIN,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
openId: OPEN_IDS.hospitalAdminA,
|
||||||
|
});
|
||||||
|
await createWithToken(server, systemAdminToken, '/users', {
|
||||||
|
name: 'Seed Hospital Admin B',
|
||||||
|
phone: EXTRA_PHONES.hospitalAdminB,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.HOSPITAL_ADMIN,
|
||||||
|
hospitalId: hospitalB.id,
|
||||||
|
openId: OPEN_IDS.hospitalAdminB,
|
||||||
|
});
|
||||||
|
|
||||||
|
const engineerA = await createWithToken(server, systemAdminToken, '/users', {
|
||||||
|
name: 'Seed Engineer A',
|
||||||
|
phone: E2E_SEED_CREDENTIALS[Role.ENGINEER].phone,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.ENGINEER,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
openId: OPEN_IDS.engineerA,
|
||||||
|
});
|
||||||
|
const engineerB = await createWithToken(server, systemAdminToken, '/users', {
|
||||||
|
name: 'Seed Engineer B',
|
||||||
|
phone: EXTRA_PHONES.engineerB,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.ENGINEER,
|
||||||
|
hospitalId: hospitalB.id,
|
||||||
|
openId: OPEN_IDS.engineerB,
|
||||||
|
});
|
||||||
|
|
||||||
|
await patchWithToken(
|
||||||
|
server,
|
||||||
|
systemAdminToken,
|
||||||
|
`/b/users/${engineerA.id}/assign-engineer-hospital`,
|
||||||
|
{ hospitalId: hospitalA.id },
|
||||||
|
);
|
||||||
|
await patchWithToken(
|
||||||
|
server,
|
||||||
|
systemAdminToken,
|
||||||
|
`/b/users/${engineerB.id}/assign-engineer-hospital`,
|
||||||
|
{ hospitalId: hospitalB.id },
|
||||||
|
);
|
||||||
|
|
||||||
|
const hospitalAdminAToken = await loginByCredential(server, {
|
||||||
|
phone: hospitalAdminA.phone,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.HOSPITAL_ADMIN,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
});
|
||||||
|
const hospitalAdminBToken = await loginByCredential(server, {
|
||||||
|
phone: EXTRA_PHONES.hospitalAdminB,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.HOSPITAL_ADMIN,
|
||||||
|
hospitalId: hospitalB.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const departmentA1 = await createWithToken(
|
||||||
|
server,
|
||||||
|
hospitalAdminAToken,
|
||||||
|
'/b/organization/departments',
|
||||||
|
{
|
||||||
|
name: FIXTURE_NAMES.departmentA1,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const departmentA2 = await createWithToken(
|
||||||
|
server,
|
||||||
|
hospitalAdminAToken,
|
||||||
|
'/b/organization/departments',
|
||||||
|
{
|
||||||
|
name: FIXTURE_NAMES.departmentA2,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const departmentB1 = await createWithToken(
|
||||||
|
server,
|
||||||
|
hospitalAdminBToken,
|
||||||
|
'/b/organization/departments',
|
||||||
|
{
|
||||||
|
name: FIXTURE_NAMES.departmentB1,
|
||||||
|
hospitalId: hospitalB.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupA1 = await createWithToken(
|
||||||
|
server,
|
||||||
|
hospitalAdminAToken,
|
||||||
|
'/b/organization/groups',
|
||||||
|
{
|
||||||
|
name: FIXTURE_NAMES.groupA1,
|
||||||
|
departmentId: departmentA1.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const groupA2 = await createWithToken(
|
||||||
|
server,
|
||||||
|
hospitalAdminAToken,
|
||||||
|
'/b/organization/groups',
|
||||||
|
{
|
||||||
|
name: FIXTURE_NAMES.groupA2,
|
||||||
|
departmentId: departmentA2.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const groupB1 = await createWithToken(
|
||||||
|
server,
|
||||||
|
hospitalAdminBToken,
|
||||||
|
'/b/organization/groups',
|
||||||
|
{
|
||||||
|
name: FIXTURE_NAMES.groupB1,
|
||||||
|
departmentId: departmentB1.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const directorA = await createWithToken(server, hospitalAdminAToken, '/users', {
|
||||||
|
name: 'Seed Director A',
|
||||||
|
phone: E2E_SEED_CREDENTIALS[Role.DIRECTOR].phone,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.DIRECTOR,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
departmentId: departmentA1.id,
|
||||||
|
openId: OPEN_IDS.directorA,
|
||||||
|
});
|
||||||
|
await createWithToken(server, hospitalAdminAToken, '/users', {
|
||||||
|
name: 'Seed Leader A',
|
||||||
|
phone: E2E_SEED_CREDENTIALS[Role.LEADER].phone,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.LEADER,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
departmentId: departmentA1.id,
|
||||||
|
groupId: groupA1.id,
|
||||||
|
openId: OPEN_IDS.leaderA,
|
||||||
|
});
|
||||||
|
const doctorA = await createWithToken(server, hospitalAdminAToken, '/users', {
|
||||||
|
name: 'Seed Doctor A',
|
||||||
|
phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.DOCTOR,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
departmentId: departmentA1.id,
|
||||||
|
groupId: groupA1.id,
|
||||||
|
openId: OPEN_IDS.doctorA,
|
||||||
|
});
|
||||||
|
const doctorA2 = await createWithToken(server, hospitalAdminAToken, '/users', {
|
||||||
|
name: 'Seed Doctor A2',
|
||||||
|
phone: EXTRA_PHONES.doctorA2,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.DOCTOR,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
departmentId: departmentA1.id,
|
||||||
|
groupId: groupA1.id,
|
||||||
|
openId: OPEN_IDS.doctorA2,
|
||||||
|
});
|
||||||
|
const doctorA3 = await createWithToken(server, hospitalAdminAToken, '/users', {
|
||||||
|
name: 'Seed Doctor A3',
|
||||||
|
phone: EXTRA_PHONES.doctorA3,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.DOCTOR,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
departmentId: departmentA2.id,
|
||||||
|
groupId: groupA2.id,
|
||||||
|
openId: OPEN_IDS.doctorA3,
|
||||||
|
});
|
||||||
|
const doctorB = await createWithToken(server, hospitalAdminBToken, '/users', {
|
||||||
|
name: 'Seed Doctor B',
|
||||||
|
phone: EXTRA_PHONES.doctorB,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.DOCTOR,
|
||||||
|
hospitalId: hospitalB.id,
|
||||||
|
departmentId: departmentB1.id,
|
||||||
|
groupId: groupB1.id,
|
||||||
|
openId: OPEN_IDS.doctorB,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bootstrapDictionaries(server, systemAdminToken);
|
||||||
|
|
||||||
|
const adjustableCatalog = await createWithToken(
|
||||||
|
server,
|
||||||
|
systemAdminToken,
|
||||||
|
'/b/devices/catalogs',
|
||||||
|
{
|
||||||
|
modelCode: FIXTURE_NAMES.adjustableCatalog,
|
||||||
|
manufacturer: 'Seed MedTech',
|
||||||
|
name: 'Seed 可调压分流阀',
|
||||||
|
pressureLevels: ['0.5', '1', '1.5'],
|
||||||
|
isPressureAdjustable: true,
|
||||||
|
notes: 'Seed 全局可调压目录样例',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await createWithToken(server, systemAdminToken, '/b/devices/catalogs', {
|
||||||
|
modelCode: FIXTURE_NAMES.highPressureCatalog,
|
||||||
|
manufacturer: 'Seed MedTech',
|
||||||
|
name: 'Seed 高压挡位阀',
|
||||||
|
pressureLevels: ['10', '20', '30'],
|
||||||
|
isPressureAdjustable: true,
|
||||||
|
notes: 'Seed 高压挡位目录样例',
|
||||||
|
});
|
||||||
|
|
||||||
|
const doctorAToken = await loginByCredential(server, {
|
||||||
|
phone: doctorA.phone,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.DOCTOR,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
});
|
||||||
|
const doctorA2Token = await loginByCredential(server, {
|
||||||
|
phone: EXTRA_PHONES.doctorA2,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.DOCTOR,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
});
|
||||||
|
const doctorA3Token = await loginByCredential(server, {
|
||||||
|
phone: EXTRA_PHONES.doctorA3,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.DOCTOR,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
});
|
||||||
|
const doctorBToken = await loginByCredential(server, {
|
||||||
|
phone: EXTRA_PHONES.doctorB,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.DOCTOR,
|
||||||
|
hospitalId: hospitalB.id,
|
||||||
|
});
|
||||||
|
const engineerAToken = await loginByCredential(server, {
|
||||||
|
phone: E2E_SEED_CREDENTIALS[Role.ENGINEER].phone,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
role: Role.ENGINEER,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patientA1 = await createWithToken(server, doctorAToken, '/b/patients', {
|
||||||
|
name: 'Seed Patient A1',
|
||||||
|
inpatientNo: 'ZYH-A-0001',
|
||||||
|
projectName: '脑积水随访项目-A',
|
||||||
|
phone: SHARED_PATIENT_IDENTITY.phone,
|
||||||
|
idCard: SHARED_PATIENT_IDENTITY.idCard,
|
||||||
|
doctorId: doctorA.id,
|
||||||
|
initialSurgery: {
|
||||||
|
surgeryDate: '2024-06-01T08:00:00.000Z',
|
||||||
|
surgeryName: '首次脑室腹腔分流术',
|
||||||
|
preOpPressure: 24,
|
||||||
|
primaryDisease: '先天性脑积水',
|
||||||
|
hydrocephalusTypes: ['交通性'],
|
||||||
|
notes: '首台手术',
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: '1',
|
||||||
|
implantNotes: 'Seed A1 弃用历史设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldDeviceId = patientA1.devices?.[0]?.id;
|
||||||
|
if (!oldDeviceId) {
|
||||||
|
throw new Error('failed to create seed old device');
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionA1 = await createWithToken(
|
||||||
|
server,
|
||||||
|
doctorAToken,
|
||||||
|
`/b/patients/${patientA1.id}/surgeries`,
|
||||||
|
{
|
||||||
|
surgeryDate: '2025-09-10T08:00:00.000Z',
|
||||||
|
surgeryName: '分流系统翻修术',
|
||||||
|
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: '二次手术,保留原设备历史',
|
||||||
|
abandonedDeviceIds: [oldDeviceId],
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: '1',
|
||||||
|
implantNotes: 'Seed A1 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const patientA2 = await createWithToken(server, doctorA2Token, '/b/patients', {
|
||||||
|
name: 'Seed Patient A2',
|
||||||
|
inpatientNo: 'ZYH-A-0002',
|
||||||
|
projectName: '脑积水随访项目-A',
|
||||||
|
phone: '13800002002',
|
||||||
|
idCard: '110101199002020022',
|
||||||
|
doctorId: doctorA2.id,
|
||||||
|
initialSurgery: {
|
||||||
|
surgeryDate: '2025-12-15T08:00:00.000Z',
|
||||||
|
surgeryName: '脑室腹腔分流术',
|
||||||
|
preOpPressure: 20,
|
||||||
|
primaryDisease: '肿瘤相关脑积水',
|
||||||
|
hydrocephalusTypes: ['梗阻性'],
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['枕角'],
|
||||||
|
valvePlacementSites: ['胸前'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: '1',
|
||||||
|
implantNotes: 'Seed A2 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createWithToken(server, doctorA3Token, '/b/patients', {
|
||||||
|
name: 'Seed Patient A3',
|
||||||
|
inpatientNo: 'ZYH-A-0003',
|
||||||
|
projectName: '脑积水随访项目-A',
|
||||||
|
phone: '13800002003',
|
||||||
|
idCard: '110101199003030033',
|
||||||
|
doctorId: doctorA3.id,
|
||||||
|
initialSurgery: {
|
||||||
|
surgeryDate: '2025-11-20T08:00:00.000Z',
|
||||||
|
surgeryName: '脑室腹腔分流术',
|
||||||
|
preOpPressure: 21,
|
||||||
|
primaryDisease: '外伤后脑积水',
|
||||||
|
hydrocephalusTypes: ['交通性'],
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
|
shuntMode: 'LPS',
|
||||||
|
proximalPunctureAreas: ['腰穿'],
|
||||||
|
valvePlacementSites: ['腰背部'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: '1',
|
||||||
|
implantNotes: 'Seed A3 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const patientB1 = await createWithToken(server, doctorBToken, '/b/patients', {
|
||||||
|
name: 'Seed Patient B1',
|
||||||
|
inpatientNo: 'ZYH-B-0001',
|
||||||
|
projectName: '脑积水随访项目-B',
|
||||||
|
phone: SHARED_PATIENT_IDENTITY.phone,
|
||||||
|
idCard: SHARED_PATIENT_IDENTITY.idCard,
|
||||||
|
doctorId: doctorB.id,
|
||||||
|
initialSurgery: {
|
||||||
|
surgeryDate: '2025-10-05T08:00:00.000Z',
|
||||||
|
surgeryName: '脑室腹腔分流术',
|
||||||
|
preOpPressure: 23,
|
||||||
|
primaryDisease: '出血后脑积水',
|
||||||
|
hydrocephalusTypes: ['高压性'],
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: '1',
|
||||||
|
implantNotes: 'Seed B1 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceA1Id = revisionA1.devices?.[0]?.id;
|
||||||
|
const deviceB1Id = patientB1.devices?.[0]?.id;
|
||||||
|
if (!deviceA1Id || !deviceB1Id) {
|
||||||
|
throw new Error('failed to create seed active devices');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedA = await createWithToken(
|
||||||
|
server,
|
||||||
|
doctorAToken,
|
||||||
|
'/b/tasks/publish',
|
||||||
|
{
|
||||||
|
engineerId: engineerA.id,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
deviceId: deviceA1Id,
|
||||||
|
targetPressure: '1.5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await createWithToken(server, engineerAToken, '/b/tasks/complete', {
|
||||||
|
taskId: publishedA.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createWithToken(server, doctorBToken, '/b/tasks/publish', {
|
||||||
|
engineerId: engineerB.id,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
deviceId: deviceB1Id,
|
||||||
|
targetPressure: '1.5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 防止未使用变量被误删,保留显式访问。
|
||||||
|
void patientA2;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapDictionaries(
|
||||||
|
server: ReturnType<INestApplication['getHttpServer']>,
|
||||||
|
systemAdminToken: string,
|
||||||
|
) {
|
||||||
|
const dictionarySeeds = {
|
||||||
|
PRIMARY_DISEASE: [
|
||||||
|
'先天性脑积水',
|
||||||
|
'梗阻性脑积水',
|
||||||
|
'交通性脑积水',
|
||||||
|
'出血后脑积水',
|
||||||
|
'肿瘤相关脑积水',
|
||||||
|
'外伤后脑积水',
|
||||||
|
'感染后脑积水',
|
||||||
|
'分流功能障碍',
|
||||||
|
],
|
||||||
|
HYDROCEPHALUS_TYPE: [
|
||||||
|
'交通性',
|
||||||
|
'梗阻性',
|
||||||
|
'高压性',
|
||||||
|
'正常压力',
|
||||||
|
'先天性',
|
||||||
|
'继发性',
|
||||||
|
],
|
||||||
|
SHUNT_MODE: ['VPS', 'VPLS', 'LPS', '脑室心房分流'],
|
||||||
|
PROXIMAL_PUNCTURE_AREA: ['额角', '枕角', '三角区', '腰穿', '后角'],
|
||||||
|
VALVE_PLACEMENT_SITE: ['耳后', '胸前', '锁骨下', '腹壁', '腰背部'],
|
||||||
|
DISTAL_SHUNT_DIRECTION: ['腹腔', '胸腔', '心房', '腰大池'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
for (const [type, labels] of Object.entries(dictionarySeeds)) {
|
||||||
|
for (const [index, label] of labels.entries()) {
|
||||||
|
await createWithToken(server, systemAdminToken, '/b/dictionaries', {
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
sortOrder: index * 10,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSystemAdmin(
|
||||||
|
server: ReturnType<INestApplication['getHttpServer']>,
|
||||||
|
) {
|
||||||
|
const response = await request(server).post('/auth/system-admin').send({
|
||||||
|
name: 'Seed System Admin',
|
||||||
|
phone: E2E_SEED_CREDENTIALS[Role.SYSTEM_ADMIN].phone,
|
||||||
|
password: E2E_SEED_PASSWORD,
|
||||||
|
openId: OPEN_IDS.systemAdmin,
|
||||||
|
systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginByCredential(
|
||||||
|
server: ReturnType<INestApplication['getHttpServer']>,
|
||||||
|
payload: {
|
||||||
|
phone: string;
|
||||||
|
password: string;
|
||||||
|
role: Role;
|
||||||
|
hospitalId?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const response = await request(server).post('/auth/login').send(payload);
|
||||||
|
expectSuccessEnvelope(response, 201);
|
||||||
|
return response.body.data.accessToken as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWithToken(
|
||||||
|
server: ReturnType<INestApplication['getHttpServer']>,
|
||||||
|
token: string,
|
||||||
|
path: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const response = await request(server)
|
||||||
|
.post(path)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 201);
|
||||||
|
return response.body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchWithToken(
|
||||||
|
server: ReturnType<INestApplication['getHttpServer']>,
|
||||||
|
token: string,
|
||||||
|
path: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const response = await request(server)
|
||||||
|
.patch(path)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
return response.body.data;
|
||||||
|
}
|
||||||
|
|
||||||
async function requireUserScope(
|
async function requireUserScope(
|
||||||
prisma: PrismaService,
|
prisma: PrismaService,
|
||||||
openId: string,
|
openId: string,
|
||||||
@ -63,16 +679,30 @@ async function requireUserScope(
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requireCatalogId(
|
||||||
|
prisma: PrismaService,
|
||||||
|
modelCode: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const catalog = await prisma.implantCatalog.findFirst({
|
||||||
|
where: { modelCode },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!catalog) {
|
||||||
|
throw new NotFoundException(`Seed catalog not found: ${modelCode}`);
|
||||||
|
}
|
||||||
|
return catalog.id;
|
||||||
|
}
|
||||||
|
|
||||||
async function requireDeviceId(
|
async function requireDeviceId(
|
||||||
prisma: PrismaService,
|
prisma: PrismaService,
|
||||||
snCode: string,
|
implantNotes: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const device = await prisma.device.findUnique({
|
const device = await prisma.device.findFirst({
|
||||||
where: { snCode },
|
where: { implantNotes },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!device) {
|
if (!device) {
|
||||||
throw new NotFoundException(`Seed device not found: ${snCode}`);
|
throw new NotFoundException(`Seed device not found: ${implantNotes}`);
|
||||||
}
|
}
|
||||||
return device.id;
|
return device.id;
|
||||||
}
|
}
|
||||||
@ -96,22 +726,16 @@ async function requirePatientId(
|
|||||||
export async function loadSeedFixtures(
|
export async function loadSeedFixtures(
|
||||||
prisma: PrismaService,
|
prisma: PrismaService,
|
||||||
): Promise<E2ESeedFixtures> {
|
): Promise<E2ESeedFixtures> {
|
||||||
const systemAdmin = await requireUserScope(
|
const systemAdmin = await requireUserScope(prisma, OPEN_IDS.systemAdmin);
|
||||||
prisma,
|
const hospitalAdminA = await requireUserScope(prisma, OPEN_IDS.hospitalAdminA);
|
||||||
'seed-system-admin-openid',
|
const directorA = await requireUserScope(prisma, OPEN_IDS.directorA);
|
||||||
);
|
const leaderA = await requireUserScope(prisma, OPEN_IDS.leaderA);
|
||||||
const hospitalAdminA = await requireUserScope(
|
const doctorA = await requireUserScope(prisma, OPEN_IDS.doctorA);
|
||||||
prisma,
|
const doctorA2 = await requireUserScope(prisma, OPEN_IDS.doctorA2);
|
||||||
'seed-hospital-admin-a-openid',
|
const doctorA3 = await requireUserScope(prisma, OPEN_IDS.doctorA3);
|
||||||
);
|
const doctorB = await requireUserScope(prisma, OPEN_IDS.doctorB);
|
||||||
const directorA = await requireUserScope(prisma, 'seed-director-a-openid');
|
const engineerA = await requireUserScope(prisma, OPEN_IDS.engineerA);
|
||||||
const leaderA = await requireUserScope(prisma, 'seed-leader-a-openid');
|
const engineerB = await requireUserScope(prisma, OPEN_IDS.engineerB);
|
||||||
const doctorA = await requireUserScope(prisma, 'seed-doctor-a-openid');
|
|
||||||
const doctorA2 = await requireUserScope(prisma, 'seed-doctor-a2-openid');
|
|
||||||
const doctorA3 = await requireUserScope(prisma, 'seed-doctor-a3-openid');
|
|
||||||
const doctorB = await requireUserScope(prisma, 'seed-doctor-b-openid');
|
|
||||||
const engineerA = await requireUserScope(prisma, 'seed-engineer-a-openid');
|
|
||||||
const engineerB = await requireUserScope(prisma, 'seed-engineer-b-openid');
|
|
||||||
|
|
||||||
const hospitalAId = hospitalAdminA.hospitalId;
|
const hospitalAId = hospitalAdminA.hospitalId;
|
||||||
const hospitalBId = doctorB.hospitalId;
|
const hospitalBId = doctorB.hospitalId;
|
||||||
@ -144,6 +768,16 @@ export async function loadSeedFixtures(
|
|||||||
groupA1Id,
|
groupA1Id,
|
||||||
groupA2Id,
|
groupA2Id,
|
||||||
groupB1Id,
|
groupB1Id,
|
||||||
|
catalogs: {
|
||||||
|
adjustableValveId: await requireCatalogId(
|
||||||
|
prisma,
|
||||||
|
FIXTURE_NAMES.adjustableCatalog,
|
||||||
|
),
|
||||||
|
highPressureValveId: await requireCatalogId(
|
||||||
|
prisma,
|
||||||
|
FIXTURE_NAMES.highPressureCatalog,
|
||||||
|
),
|
||||||
|
},
|
||||||
users: {
|
users: {
|
||||||
systemAdminId: systemAdmin.id,
|
systemAdminId: systemAdmin.id,
|
||||||
hospitalAdminAId: hospitalAdminA.id,
|
hospitalAdminAId: hospitalAdminA.id,
|
||||||
@ -160,8 +794,8 @@ export async function loadSeedFixtures(
|
|||||||
patientA1Id: await requirePatientId(
|
patientA1Id: await requirePatientId(
|
||||||
prisma,
|
prisma,
|
||||||
hospitalAId,
|
hospitalAId,
|
||||||
'13800002001',
|
SHARED_PATIENT_IDENTITY.phone,
|
||||||
'110101199001010011',
|
SHARED_PATIENT_IDENTITY.idCard,
|
||||||
),
|
),
|
||||||
patientA2Id: await requirePatientId(
|
patientA2Id: await requirePatientId(
|
||||||
prisma,
|
prisma,
|
||||||
@ -178,16 +812,16 @@ export async function loadSeedFixtures(
|
|||||||
patientB1Id: await requirePatientId(
|
patientB1Id: await requirePatientId(
|
||||||
prisma,
|
prisma,
|
||||||
hospitalBId,
|
hospitalBId,
|
||||||
'13800002001',
|
SHARED_PATIENT_IDENTITY.phone,
|
||||||
'110101199001010011',
|
SHARED_PATIENT_IDENTITY.idCard,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
deviceA1Id: await requireDeviceId(prisma, 'SEED-SN-A-001'),
|
deviceA1Id: await requireDeviceId(prisma, 'Seed A1 当前在用设备'),
|
||||||
deviceA2Id: await requireDeviceId(prisma, 'SEED-SN-A-002'),
|
deviceA2Id: await requireDeviceId(prisma, 'Seed A2 当前在用设备'),
|
||||||
deviceA3Id: await requireDeviceId(prisma, 'SEED-SN-A-003'),
|
deviceA3Id: await requireDeviceId(prisma, 'Seed A3 当前在用设备'),
|
||||||
deviceA4InactiveId: await requireDeviceId(prisma, 'SEED-SN-A-004'),
|
deviceA4InactiveId: await requireDeviceId(prisma, 'Seed A1 弃用历史设备'),
|
||||||
deviceB1Id: await requireDeviceId(prisma, 'SEED-SN-B-001'),
|
deviceB1Id: await requireDeviceId(prisma, 'Seed B1 当前在用设备'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,6 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
.post('/b/devices')
|
.post('/b/devices')
|
||||||
.set('Authorization', `Bearer ${token}`)
|
.set('Authorization', `Bearer ${token}`)
|
||||||
.send({
|
.send({
|
||||||
snCode: uniqueSeedValue('device-sn'),
|
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
patientId,
|
patientId,
|
||||||
});
|
});
|
||||||
@ -36,8 +35,7 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
expectSuccessEnvelope(response, 201);
|
expectSuccessEnvelope(response, 201);
|
||||||
return response.body.data as {
|
return response.body.data as {
|
||||||
id: number;
|
id: number;
|
||||||
snCode: string;
|
currentPressure: string;
|
||||||
currentPressure: number;
|
|
||||||
status: DeviceStatus;
|
status: DeviceStatus;
|
||||||
patient: { id: number };
|
patient: { id: number };
|
||||||
};
|
};
|
||||||
@ -118,24 +116,28 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
manufacturer: 'Global Vendor',
|
manufacturer: 'Global Vendor',
|
||||||
name: '全局可调压阀',
|
name: '全局可调压阀',
|
||||||
isPressureAdjustable: true,
|
isPressureAdjustable: true,
|
||||||
pressureLevels: [70, 90, 110],
|
pressureLevels: ['10.0', '20', '30.0'],
|
||||||
notes: '测试全局目录',
|
notes: '测试全局目录',
|
||||||
});
|
});
|
||||||
|
|
||||||
expectSuccessEnvelope(createResponse, 201);
|
expectSuccessEnvelope(createResponse, 201);
|
||||||
expect(createResponse.body.data.pressureLevels).toEqual([70, 90, 110]);
|
expect(createResponse.body.data.pressureLevels).toEqual(['10', '20', '30']);
|
||||||
|
|
||||||
const updateResponse = await request(ctx.app.getHttpServer())
|
const updateResponse = await request(ctx.app.getHttpServer())
|
||||||
.patch(`/b/devices/catalogs/${createResponse.body.data.id}`)
|
.patch(`/b/devices/catalogs/${createResponse.body.data.id}`)
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
.send({
|
.send({
|
||||||
name: '全局可调压阀-更新版',
|
name: '全局可调压阀-更新版',
|
||||||
pressureLevels: [80, 100, 120],
|
pressureLevels: ['0.5', '1.0', '1.50'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expectSuccessEnvelope(updateResponse, 200);
|
expectSuccessEnvelope(updateResponse, 200);
|
||||||
expect(updateResponse.body.data.name).toBe('全局可调压阀-更新版');
|
expect(updateResponse.body.data.name).toBe('全局可调压阀-更新版');
|
||||||
expect(updateResponse.body.data.pressureLevels).toEqual([80, 100, 120]);
|
expect(updateResponse.body.data.pressureLevels).toEqual([
|
||||||
|
'0.5',
|
||||||
|
'1',
|
||||||
|
'1.5',
|
||||||
|
]);
|
||||||
|
|
||||||
const deleteResponse = await request(ctx.app.getHttpServer())
|
const deleteResponse = await request(ctx.app.getHttpServer())
|
||||||
.delete(`/b/devices/catalogs/${createResponse.body.data.id}`)
|
.delete(`/b/devices/catalogs/${createResponse.body.data.id}`)
|
||||||
@ -166,7 +168,7 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
manufacturer: 'Role Matrix Vendor',
|
manufacturer: 'Role Matrix Vendor',
|
||||||
name: '角色矩阵目录',
|
name: '角色矩阵目录',
|
||||||
isPressureAdjustable: true,
|
isPressureAdjustable: true,
|
||||||
pressureLevels: [50, 80],
|
pressureLevels: ['10', '20'],
|
||||||
}),
|
}),
|
||||||
sendWithoutToken: async () =>
|
sendWithoutToken: async () =>
|
||||||
request(ctx.app.getHttpServer())
|
request(ctx.app.getHttpServer())
|
||||||
@ -188,9 +190,9 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(created.status).toBe(DeviceStatus.ACTIVE);
|
expect(created.status).toBe(DeviceStatus.ACTIVE);
|
||||||
expect(created.currentPressure).toBe(0);
|
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).not.toHaveProperty('snCode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:HOSPITAL_ADMIN 绑定跨院患者返回 403', async () => {
|
it('失败:HOSPITAL_ADMIN 绑定跨院患者返回 403', async () => {
|
||||||
@ -198,7 +200,6 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
.post('/b/devices')
|
.post('/b/devices')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||||
.send({
|
.send({
|
||||||
snCode: uniqueSeedValue('cross-hospital-device'),
|
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
patientId: ctx.fixtures.patients.patientB1Id,
|
patientId: ctx.fixtures.patients.patientB1Id,
|
||||||
});
|
});
|
||||||
@ -225,7 +226,7 @@ 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(0);
|
expect(response.body.data.currentPressure).toBe('0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:设备实例接口不允许手工更新 currentPressure', async () => {
|
it('失败:设备实例接口不允许手工更新 currentPressure', async () => {
|
||||||
@ -238,7 +239,7 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
.patch(`/b/devices/${created.id}`)
|
.patch(`/b/devices/${created.id}`)
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
.send({
|
.send({
|
||||||
currentPressure: 99,
|
currentPressure: '1.5',
|
||||||
});
|
});
|
||||||
|
|
||||||
expectErrorEnvelope(response, 400, 'currentPressure should not exist');
|
expectErrorEnvelope(response, 400, 'currentPressure should not exist');
|
||||||
|
|||||||
@ -321,7 +321,7 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /b/organization/departments role matrix',
|
name: 'GET /b/organization/departments role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
@ -330,7 +330,7 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 200,
|
||||||
[Role.LEADER]: 200,
|
[Role.LEADER]: 200,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 200,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
sendAsRole: async (_role, token) =>
|
sendAsRole: async (_role, token) =>
|
||||||
@ -361,7 +361,7 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /b/organization/departments/:id role matrix',
|
name: 'GET /b/organization/departments/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
@ -370,7 +370,7 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 200,
|
||||||
[Role.LEADER]: 200,
|
[Role.LEADER]: 200,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 200,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
sendAsRole: async (_role, token) =>
|
sendAsRole: async (_role, token) =>
|
||||||
@ -413,15 +413,15 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'PATCH /b/organization/departments/:id role matrix',
|
name: 'PATCH /b/organization/departments/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 404,
|
[Role.SYSTEM_ADMIN]: 404,
|
||||||
[Role.HOSPITAL_ADMIN]: 404,
|
[Role.HOSPITAL_ADMIN]: 404,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 404,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -516,14 +516,14 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/organization/groups role matrix',
|
name: 'POST /b/organization/groups role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 400,
|
[Role.SYSTEM_ADMIN]: 400,
|
||||||
[Role.HOSPITAL_ADMIN]: 400,
|
[Role.HOSPITAL_ADMIN]: 400,
|
||||||
[Role.DIRECTOR]: 400,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
@ -558,7 +558,7 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /b/organization/groups role matrix',
|
name: 'GET /b/organization/groups role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
@ -567,7 +567,7 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 200,
|
||||||
[Role.LEADER]: 200,
|
[Role.LEADER]: 200,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 200,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
sendAsRole: async (_role, token) =>
|
sendAsRole: async (_role, token) =>
|
||||||
@ -598,7 +598,7 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /b/organization/groups/:id role matrix',
|
name: 'GET /b/organization/groups/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
@ -607,7 +607,7 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 200,
|
||||||
[Role.LEADER]: 200,
|
[Role.LEADER]: 200,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 200,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
sendAsRole: async (_role, token) =>
|
sendAsRole: async (_role, token) =>
|
||||||
@ -650,15 +650,15 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'PATCH /b/organization/groups/:id role matrix',
|
name: 'PATCH /b/organization/groups/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 404,
|
[Role.SYSTEM_ADMIN]: 404,
|
||||||
[Role.HOSPITAL_ADMIN]: 404,
|
[Role.HOSPITAL_ADMIN]: 404,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 404,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -710,14 +710,14 @@ describe('Organization Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 409, '小组下仍有成员,无法删除');
|
expectErrorEnvelope(response, 409, '小组下仍有成员,无法删除');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'DELETE /b/organization/groups/:id role matrix',
|
name: 'DELETE /b/organization/groups/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 404,
|
[Role.SYSTEM_ADMIN]: 404,
|
||||||
[Role.HOSPITAL_ADMIN]: 404,
|
[Role.HOSPITAL_ADMIN]: 404,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import {
|
import { DeviceStatus, Role } from '../../../src/generated/prisma/enums.js';
|
||||||
DeviceStatus,
|
|
||||||
Role,
|
|
||||||
TaskStatus,
|
|
||||||
} from '../../../src/generated/prisma/enums.js';
|
|
||||||
import {
|
import {
|
||||||
closeE2EContext,
|
closeE2EContext,
|
||||||
createE2EContext,
|
createE2EContext,
|
||||||
@ -201,12 +197,6 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
|
|
||||||
describe('患者手术录入', () => {
|
describe('患者手术录入', () => {
|
||||||
it('成功:DOCTOR 可创建患者并附带首台手术和植入设备', async () => {
|
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())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/patients')
|
.post('/b/patients')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
@ -220,18 +210,17 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
initialSurgery: {
|
initialSurgery: {
|
||||||
surgeryDate: '2026-03-19T08:00:00.000Z',
|
surgeryDate: '2026-03-19T08:00:00.000Z',
|
||||||
surgeryName: '脑室腹腔分流术',
|
surgeryName: '脑室腹腔分流术',
|
||||||
surgeonName: 'Seed Doctor A',
|
|
||||||
preOpPressure: 20,
|
preOpPressure: 20,
|
||||||
primaryDisease: '梗阻性脑积水',
|
primaryDisease: '梗阻性脑积水',
|
||||||
hydrocephalusTypes: ['交通性'],
|
hydrocephalusTypes: ['交通性'],
|
||||||
devices: [
|
devices: [
|
||||||
{
|
{
|
||||||
implantCatalogId: adjustableCatalog!.id,
|
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||||||
shuntMode: 'VPS',
|
shuntMode: 'VPS',
|
||||||
proximalPunctureAreas: ['额角'],
|
proximalPunctureAreas: ['额角'],
|
||||||
valvePlacementSites: ['耳后'],
|
valvePlacementSites: ['耳后'],
|
||||||
distalShuntDirection: '腹腔',
|
distalShuntDirection: '腹腔',
|
||||||
initialPressure: 120,
|
initialPressure: '1',
|
||||||
implantNotes: '首术植入',
|
implantNotes: '首术植入',
|
||||||
labelImageUrl:
|
labelImageUrl:
|
||||||
'https://seed.example.com/tests/first-surgery.jpg',
|
'https://seed.example.com/tests/first-surgery.jpg',
|
||||||
@ -241,8 +230,18 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 201);
|
expectSuccessEnvelope(response, 201);
|
||||||
|
expect(response.body.data.creatorId).toBe(ctx.fixtures.users.doctorAId);
|
||||||
|
expect(response.body.data.creator).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: ctx.fixtures.users.doctorAId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(response.body.data.createdAt).toBeTruthy();
|
||||||
expect(response.body.data.shuntSurgeryCount).toBe(1);
|
expect(response.body.data.shuntSurgeryCount).toBe(1);
|
||||||
expect(response.body.data.surgeries).toHaveLength(1);
|
expect(response.body.data.surgeries).toHaveLength(1);
|
||||||
|
expect(response.body.data.surgeries[0].surgeonId).toBe(
|
||||||
|
ctx.fixtures.users.doctorAId,
|
||||||
|
);
|
||||||
expect(response.body.data.surgeries[0].devices).toHaveLength(1);
|
expect(response.body.data.surgeries[0].devices).toHaveLength(1);
|
||||||
expect(response.body.data.surgeries[0].devices[0].implantModel).toBe(
|
expect(response.body.data.surgeries[0].devices[0].implantModel).toBe(
|
||||||
'SEED-ADJUSTABLE-VALVE',
|
'SEED-ADJUSTABLE-VALVE',
|
||||||
@ -250,12 +249,6 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('成功:新增二次手术时可弃用旧设备且保留旧设备调压历史', async () => {
|
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())
|
const createPatientResponse = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/patients')
|
.post('/b/patients')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
@ -269,18 +262,17 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
initialSurgery: {
|
initialSurgery: {
|
||||||
surgeryDate: '2026-03-01T08:00:00.000Z',
|
surgeryDate: '2026-03-01T08:00:00.000Z',
|
||||||
surgeryName: '首次分流术',
|
surgeryName: '首次分流术',
|
||||||
surgeonName: 'Seed Doctor A',
|
|
||||||
preOpPressure: 18,
|
preOpPressure: 18,
|
||||||
primaryDisease: '出血后脑积水',
|
primaryDisease: '出血后脑积水',
|
||||||
hydrocephalusTypes: ['高压性'],
|
hydrocephalusTypes: ['高压性'],
|
||||||
devices: [
|
devices: [
|
||||||
{
|
{
|
||||||
implantCatalogId: adjustableCatalog!.id,
|
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||||||
shuntMode: 'VPS',
|
shuntMode: 'VPS',
|
||||||
proximalPunctureAreas: ['额角'],
|
proximalPunctureAreas: ['额角'],
|
||||||
valvePlacementSites: ['耳后'],
|
valvePlacementSites: ['耳后'],
|
||||||
distalShuntDirection: '腹腔',
|
distalShuntDirection: '腹腔',
|
||||||
initialPressure: 100,
|
initialPressure: '1',
|
||||||
implantNotes: '首术设备',
|
implantNotes: '首术设备',
|
||||||
labelImageUrl:
|
labelImageUrl:
|
||||||
'https://seed.example.com/tests/initial-device.jpg',
|
'https://seed.example.com/tests/initial-device.jpg',
|
||||||
@ -296,23 +288,25 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
};
|
};
|
||||||
const oldDeviceId = patient.devices[0].id;
|
const oldDeviceId = patient.devices[0].id;
|
||||||
|
|
||||||
await ctx.prisma.task.create({
|
const publishResponse = await request(ctx.app.getHttpServer())
|
||||||
data: {
|
.post('/b/tasks/publish')
|
||||||
status: TaskStatus.COMPLETED,
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
creatorId: ctx.fixtures.users.doctorAId,
|
.send({
|
||||||
engineerId: ctx.fixtures.users.engineerAId,
|
engineerId: ctx.fixtures.users.engineerAId,
|
||||||
hospitalId: ctx.fixtures.hospitalAId,
|
items: [
|
||||||
items: {
|
|
||||||
create: [
|
|
||||||
{
|
{
|
||||||
deviceId: oldDeviceId,
|
deviceId: oldDeviceId,
|
||||||
oldPressure: 100,
|
targetPressure: '1.5',
|
||||||
targetPressure: 120,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
expectSuccessEnvelope(publishResponse, 201);
|
||||||
|
|
||||||
|
const completeResponse = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/tasks/complete')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
|
.send({ taskId: publishResponse.body.data.id });
|
||||||
|
expectSuccessEnvelope(completeResponse, 201);
|
||||||
|
|
||||||
const surgeryResponse = await request(ctx.app.getHttpServer())
|
const surgeryResponse = await request(ctx.app.getHttpServer())
|
||||||
.post(`/b/patients/${patient.id}/surgeries`)
|
.post(`/b/patients/${patient.id}/surgeries`)
|
||||||
@ -320,29 +314,28 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
.send({
|
.send({
|
||||||
surgeryDate: '2026-03-18T08:00:00.000Z',
|
surgeryDate: '2026-03-18T08:00:00.000Z',
|
||||||
surgeryName: '二次翻修术',
|
surgeryName: '二次翻修术',
|
||||||
surgeonName: 'Seed Doctor A',
|
|
||||||
preOpPressure: 16,
|
preOpPressure: 16,
|
||||||
primaryDisease: '分流功能障碍',
|
primaryDisease: '分流功能障碍',
|
||||||
hydrocephalusTypes: ['交通性', '高压性'],
|
hydrocephalusTypes: ['交通性', '高压性'],
|
||||||
abandonedDeviceIds: [oldDeviceId],
|
abandonedDeviceIds: [oldDeviceId],
|
||||||
devices: [
|
devices: [
|
||||||
{
|
{
|
||||||
implantCatalogId: adjustableCatalog!.id,
|
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||||||
shuntMode: 'VPS',
|
shuntMode: 'VPS',
|
||||||
proximalPunctureAreas: ['枕角'],
|
proximalPunctureAreas: ['枕角'],
|
||||||
valvePlacementSites: ['耳后'],
|
valvePlacementSites: ['耳后'],
|
||||||
distalShuntDirection: '腹腔',
|
distalShuntDirection: '腹腔',
|
||||||
initialPressure: 120,
|
initialPressure: '1',
|
||||||
implantNotes: '二次手术新设备-1',
|
implantNotes: '二次手术新设备-1',
|
||||||
labelImageUrl: 'https://seed.example.com/tests/revision-1.jpg',
|
labelImageUrl: 'https://seed.example.com/tests/revision-1.jpg',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
implantCatalogId: adjustableCatalog!.id,
|
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||||||
shuntMode: 'VPS',
|
shuntMode: 'VPS',
|
||||||
proximalPunctureAreas: ['额角'],
|
proximalPunctureAreas: ['额角'],
|
||||||
valvePlacementSites: ['胸前'],
|
valvePlacementSites: ['胸前'],
|
||||||
distalShuntDirection: '胸腔',
|
distalShuntDirection: '胸腔',
|
||||||
initialPressure: 140,
|
initialPressure: '1.5',
|
||||||
implantNotes: '二次手术新设备-2',
|
implantNotes: '二次手术新设备-2',
|
||||||
labelImageUrl: 'https://seed.example.com/tests/revision-2.jpg',
|
labelImageUrl: 'https://seed.example.com/tests/revision-2.jpg',
|
||||||
},
|
},
|
||||||
@ -364,12 +357,6 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('失败:手术录入设备不允许手工传 currentPressure', async () => {
|
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())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/patients')
|
.post('/b/patients')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
@ -383,18 +370,17 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
initialSurgery: {
|
initialSurgery: {
|
||||||
surgeryDate: '2026-03-19T08:00:00.000Z',
|
surgeryDate: '2026-03-19T08:00:00.000Z',
|
||||||
surgeryName: '脑室腹腔分流术',
|
surgeryName: '脑室腹腔分流术',
|
||||||
surgeonName: 'Seed Doctor A',
|
|
||||||
primaryDisease: '梗阻性脑积水',
|
primaryDisease: '梗阻性脑积水',
|
||||||
hydrocephalusTypes: ['交通性'],
|
hydrocephalusTypes: ['交通性'],
|
||||||
devices: [
|
devices: [
|
||||||
{
|
{
|
||||||
implantCatalogId: adjustableCatalog!.id,
|
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||||||
shuntMode: 'VPS',
|
shuntMode: 'VPS',
|
||||||
proximalPunctureAreas: ['额角'],
|
proximalPunctureAreas: ['额角'],
|
||||||
valvePlacementSites: ['耳后'],
|
valvePlacementSites: ['耳后'],
|
||||||
distalShuntDirection: '腹腔',
|
distalShuntDirection: '腹腔',
|
||||||
initialPressure: 120,
|
initialPressure: '1',
|
||||||
currentPressure: 120,
|
currentPressure: '1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -22,11 +22,17 @@ describe('BTasksController (e2e)', () => {
|
|||||||
await closeE2EContext(ctx);
|
await closeE2EContext(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function publishPendingTask(deviceId: number, targetPressure: number) {
|
async function publishAssignedTask(
|
||||||
|
deviceId: number,
|
||||||
|
targetPressure: string,
|
||||||
|
actorToken = ctx.tokens[Role.DOCTOR],
|
||||||
|
engineerId = ctx.fixtures.users.engineerAId,
|
||||||
|
) {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/publish')
|
.post('/b/tasks/publish')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${actorToken}`)
|
||||||
.send({
|
.send({
|
||||||
|
engineerId,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId,
|
deviceId,
|
||||||
@ -36,11 +42,123 @@ describe('BTasksController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 201);
|
expectSuccessEnvelope(response, 201);
|
||||||
return response.body.data as { id: number; status: TaskStatus };
|
return response.body.data as {
|
||||||
|
id: number;
|
||||||
|
status: TaskStatus;
|
||||||
|
engineerId: number;
|
||||||
|
hospitalId: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('GET /b/tasks/engineers', () => {
|
||||||
|
it('成功:DOCTOR 可查看本院可选工程师', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/tasks/engineers')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(Array.isArray(response.body.data)).toBe(true);
|
||||||
|
expect(response.body.data).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: ctx.fixtures.users.engineerAId,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功:SYSTEM_ADMIN 可按医院筛选工程师', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/tasks/engineers')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
|
.query({ hospitalId: ctx.fixtures.hospitalBId });
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(response.body.data).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: ctx.fixtures.users.engineerBId,
|
||||||
|
hospitalId: ctx.fixtures.hospitalBId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败:hospitalId 非法返回 400', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/tasks/engineers')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
|
.query({ hospitalId: 0 });
|
||||||
|
|
||||||
|
expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /b/tasks', () => {
|
||||||
|
it('成功:SYSTEM_ADMIN 可查看跨医院调压记录', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/tasks')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
|
.query({ page: 1, pageSize: 20 });
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(Array.isArray(response.body.data.list)).toBe(true);
|
||||||
|
expect(response.body.data.total).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
response.body.data.list.every(
|
||||||
|
(item: {
|
||||||
|
creator?: { id?: number; name?: string };
|
||||||
|
engineer?: { id?: number; name?: string };
|
||||||
|
}) =>
|
||||||
|
Number.isInteger(item.creator?.id) &&
|
||||||
|
Boolean(item.creator?.name) &&
|
||||||
|
Number.isInteger(item.engineer?.id) &&
|
||||||
|
Boolean(item.engineer?.name),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const hospitalIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
response.body.data.list
|
||||||
|
.map((item: { hospital?: { id?: number } }) => item.hospital?.id)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(hospitalIds).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
ctx.fixtures.hospitalAId,
|
||||||
|
ctx.fixtures.hospitalBId,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功:DOCTOR 仅可查看本院调压记录', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/tasks')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
|
.query({ page: 1, pageSize: 20 });
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(Array.isArray(response.body.data.list)).toBe(true);
|
||||||
|
expect(response.body.data.total).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
response.body.data.list.every(
|
||||||
|
(item: { hospital?: { id?: number } }) =>
|
||||||
|
item.hospital?.id === ctx.fixtures.hospitalAId,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败:hospitalId 非法返回 400', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/tasks')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
|
.query({ hospitalId: 0 });
|
||||||
|
|
||||||
|
expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /b/tasks/publish', () => {
|
describe('POST /b/tasks/publish', () => {
|
||||||
it('成功:DOCTOR 可发布任务', async () => {
|
it('成功:DOCTOR 发布任务时必须直接指定接收工程师', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/publish')
|
.post('/b/tasks/publish')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
@ -49,16 +167,30 @@ describe('BTasksController (e2e)', () => {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId: ctx.fixtures.devices.deviceA2Id,
|
deviceId: ctx.fixtures.devices.deviceA2Id,
|
||||||
targetPressure: 120,
|
targetPressure: '1.5',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 201);
|
expectSuccessEnvelope(response, 201);
|
||||||
expect(response.body.data.status).toBe(TaskStatus.PENDING);
|
expect(response.body.data.status).toBe(TaskStatus.ACCEPTED);
|
||||||
|
expect(response.body.data.engineerId).toBe(
|
||||||
|
ctx.fixtures.users.engineerAId,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:可调压设备使用非法挡位返回 400', async () => {
|
it('成功:SYSTEM_ADMIN 可按设备自动归院发布任务', async () => {
|
||||||
|
const task = await publishAssignedTask(
|
||||||
|
ctx.fixtures.devices.deviceA1Id,
|
||||||
|
'1.5',
|
||||||
|
ctx.tokens[Role.SYSTEM_ADMIN],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.status).toBe(TaskStatus.ACCEPTED);
|
||||||
|
expect(task.hospitalId).toBe(ctx.fixtures.hospitalAId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败:未指定接收工程师返回 400', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/publish')
|
.post('/b/tasks/publish')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
@ -66,7 +198,24 @@ describe('BTasksController (e2e)', () => {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId: ctx.fixtures.devices.deviceA2Id,
|
deviceId: ctx.fixtures.devices.deviceA2Id,
|
||||||
targetPressure: 126,
|
targetPressure: '1.5',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expectErrorEnvelope(response, 400, 'engineerId 必须是整数');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败:可调压设备使用非法挡位返回 400', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/tasks/publish')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
|
.send({
|
||||||
|
engineerId: ctx.fixtures.users.engineerAId,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
deviceId: ctx.fixtures.devices.deviceA2Id,
|
||||||
|
targetPressure: '2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -79,10 +228,11 @@ describe('BTasksController (e2e)', () => {
|
|||||||
.post('/b/tasks/publish')
|
.post('/b/tasks/publish')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
.send({
|
.send({
|
||||||
|
engineerId: ctx.fixtures.users.engineerAId,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId: ctx.fixtures.devices.deviceB1Id,
|
deviceId: ctx.fixtures.devices.deviceB1Id,
|
||||||
targetPressure: 120,
|
targetPressure: '1.5',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -90,13 +240,13 @@ describe('BTasksController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
|
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/tasks/publish role matrix',
|
name: 'POST /b/tasks/publish role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 403,
|
[Role.SYSTEM_ADMIN]: 400,
|
||||||
[Role.HOSPITAL_ADMIN]: 403,
|
[Role.HOSPITAL_ADMIN]: 400,
|
||||||
[Role.DIRECTOR]: 400,
|
[Role.DIRECTOR]: 400,
|
||||||
[Role.LEADER]: 400,
|
[Role.LEADER]: 400,
|
||||||
[Role.DOCTOR]: 400,
|
[Role.DOCTOR]: 400,
|
||||||
@ -114,10 +264,10 @@ describe('BTasksController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /b/tasks/accept', () => {
|
describe('POST /b/tasks/accept', () => {
|
||||||
it('成功:ENGINEER 可接收待处理任务', async () => {
|
it('失败:ENGINEER 接收接口已停用,返回 403', async () => {
|
||||||
const task = await publishPendingTask(
|
const task = await publishAssignedTask(
|
||||||
ctx.fixtures.devices.deviceA2Id,
|
ctx.fixtures.devices.deviceA2Id,
|
||||||
140,
|
'1.5',
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
@ -125,43 +275,14 @@ describe('BTasksController (e2e)', () => {
|
|||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
.send({ taskId: task.id });
|
.send({ taskId: task.id });
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 201);
|
expectErrorEnvelope(
|
||||||
expect(response.body.data.status).toBe(TaskStatus.ACCEPTED);
|
response,
|
||||||
expect(response.body.data.engineerId).toBe(
|
403,
|
||||||
ctx.fixtures.users.engineerAId,
|
'当前流程不支持工程师接收,请由创建人直接指定接收工程师',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:接收不存在任务返回 404', async () => {
|
it('角色矩阵:接收接口对所有角色都不可用,未登录 401', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.post('/b/tasks/accept')
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
|
||||||
.send({ taskId: 99999999 });
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('状态机失败:重复接收返回 409', async () => {
|
|
||||||
const task = await publishPendingTask(
|
|
||||||
ctx.fixtures.devices.deviceA3Id,
|
|
||||||
120,
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstAccept = await request(ctx.app.getHttpServer())
|
|
||||||
.post('/b/tasks/accept')
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
|
||||||
.send({ taskId: task.id });
|
|
||||||
expectSuccessEnvelope(firstAccept, 201);
|
|
||||||
|
|
||||||
const secondAccept = await request(ctx.app.getHttpServer())
|
|
||||||
.post('/b/tasks/accept')
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
|
||||||
.send({ taskId: task.id });
|
|
||||||
|
|
||||||
expectErrorEnvelope(secondAccept, 409, '仅待接收任务可执行接收');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => {
|
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/tasks/accept role matrix',
|
name: 'POST /b/tasks/accept role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
@ -171,7 +292,7 @@ describe('BTasksController (e2e)', () => {
|
|||||||
[Role.DIRECTOR]: 403,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 404,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
sendAsRole: async (_role, token) =>
|
sendAsRole: async (_role, token) =>
|
||||||
request(ctx.app.getHttpServer())
|
request(ctx.app.getHttpServer())
|
||||||
@ -187,19 +308,13 @@ describe('BTasksController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /b/tasks/complete', () => {
|
describe('POST /b/tasks/complete', () => {
|
||||||
it('成功:ENGINEER 完成已接收任务并同步设备压力', async () => {
|
it('成功:ENGINEER 可直接完成已指派任务并同步设备压力', async () => {
|
||||||
const targetPressure = 140;
|
const targetPressure = '1.5';
|
||||||
const task = await publishPendingTask(
|
const task = await publishAssignedTask(
|
||||||
ctx.fixtures.devices.deviceA1Id,
|
ctx.fixtures.devices.deviceA1Id,
|
||||||
targetPressure,
|
targetPressure,
|
||||||
);
|
);
|
||||||
|
|
||||||
const acceptResponse = await request(ctx.app.getHttpServer())
|
|
||||||
.post('/b/tasks/accept')
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
|
||||||
.send({ taskId: task.id });
|
|
||||||
expectSuccessEnvelope(acceptResponse, 201);
|
|
||||||
|
|
||||||
const completeResponse = await request(ctx.app.getHttpServer())
|
const completeResponse = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/complete')
|
.post('/b/tasks/complete')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
@ -224,18 +339,24 @@ describe('BTasksController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('状态机失败:未接收任务直接完成返回 409', async () => {
|
it('状态机失败:重复完成返回 409', async () => {
|
||||||
const task = await publishPendingTask(
|
const task = await publishAssignedTask(
|
||||||
ctx.fixtures.devices.deviceA2Id,
|
ctx.fixtures.devices.deviceA2Id,
|
||||||
100,
|
'1',
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const firstComplete = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/tasks/complete')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
|
.send({ taskId: task.id });
|
||||||
|
expectSuccessEnvelope(firstComplete, 201);
|
||||||
|
|
||||||
|
const secondComplete = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/complete')
|
.post('/b/tasks/complete')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
.send({ taskId: task.id });
|
.send({ taskId: task.id });
|
||||||
|
|
||||||
expectErrorEnvelope(response, 409, '仅已接收任务可执行完成');
|
expectErrorEnvelope(secondComplete, 409, '仅已指派任务可执行完成');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => {
|
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
@ -264,10 +385,10 @@ describe('BTasksController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /b/tasks/cancel', () => {
|
describe('POST /b/tasks/cancel', () => {
|
||||||
it('成功:DOCTOR 可取消自己创建的任务', async () => {
|
it('成功:DOCTOR 可取消自己创建的已指派任务', async () => {
|
||||||
const task = await publishPendingTask(
|
const task = await publishAssignedTask(
|
||||||
ctx.fixtures.devices.deviceA3Id,
|
ctx.fixtures.devices.deviceA3Id,
|
||||||
120,
|
'1.5',
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
@ -289,17 +410,11 @@ describe('BTasksController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('状态机失败:已完成任务不可取消返回 409', async () => {
|
it('状态机失败:已完成任务不可取消返回 409', async () => {
|
||||||
const task = await publishPendingTask(
|
const task = await publishAssignedTask(
|
||||||
ctx.fixtures.devices.deviceA2Id,
|
ctx.fixtures.devices.deviceA2Id,
|
||||||
160,
|
'1.5',
|
||||||
);
|
);
|
||||||
|
|
||||||
const acceptResponse = await request(ctx.app.getHttpServer())
|
|
||||||
.post('/b/tasks/accept')
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
|
||||||
.send({ taskId: task.id });
|
|
||||||
expectSuccessEnvelope(acceptResponse, 201);
|
|
||||||
|
|
||||||
const completeResponse = await request(ctx.app.getHttpServer())
|
const completeResponse = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/complete')
|
.post('/b/tasks/complete')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
@ -311,16 +426,16 @@ describe('BTasksController (e2e)', () => {
|
|||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
.send({ taskId: task.id });
|
.send({ taskId: task.id });
|
||||||
|
|
||||||
expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消');
|
expectErrorEnvelope(cancelResponse, 409, '仅待指派/已指派任务可取消');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/tasks/cancel role matrix',
|
name: 'POST /b/tasks/cancel role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 403,
|
[Role.SYSTEM_ADMIN]: 404,
|
||||||
[Role.HOSPITAL_ADMIN]: 403,
|
[Role.HOSPITAL_ADMIN]: 404,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 404,
|
||||||
[Role.LEADER]: 404,
|
[Role.LEADER]: 404,
|
||||||
[Role.DOCTOR]: 404,
|
[Role.DOCTOR]: 404,
|
||||||
|
|||||||
225
test/e2e/specs/uploads.e2e-spec.ts
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import ffmpegPath from 'ffmpeg-static';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { Role } from '../../../src/generated/prisma/enums.js';
|
||||||
|
import {
|
||||||
|
closeE2EContext,
|
||||||
|
createE2EContext,
|
||||||
|
type E2EContext,
|
||||||
|
} from '../helpers/e2e-context.helper.js';
|
||||||
|
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
|
||||||
|
import {
|
||||||
|
expectErrorEnvelope,
|
||||||
|
expectSuccessEnvelope,
|
||||||
|
} from '../helpers/e2e-http.helper.js';
|
||||||
|
|
||||||
|
describe('BUploadsController (e2e)', () => {
|
||||||
|
let ctx: E2EContext;
|
||||||
|
let tempDir = '';
|
||||||
|
let sampleVideoPath = '';
|
||||||
|
let samplePngBuffer: Buffer;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createE2EContext();
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), 'upload-assets-e2e-'));
|
||||||
|
sampleVideoPath = join(tempDir, 'ct-video.mov');
|
||||||
|
await createSampleVideo(sampleVideoPath);
|
||||||
|
samplePngBuffer = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
channels: 3,
|
||||||
|
background: { r: 24, g: 46, b: 82 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await closeE2EContext(ctx);
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功:HOSPITAL_ADMIN 可上传图片', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/uploads')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||||
|
.attach('file', samplePngBuffer, {
|
||||||
|
filename: 'ct-image.png',
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 201);
|
||||||
|
expect(response.body.data.type).toBe('IMAGE');
|
||||||
|
expect(response.body.data.mimeType).toBe('image/webp');
|
||||||
|
expect(response.body.data.fileName).toMatch(/^\d{14}-ct-image\.webp$/);
|
||||||
|
expect(response.body.data.storagePath).toMatch(
|
||||||
|
/^\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-image\.webp$/,
|
||||||
|
);
|
||||||
|
expect(response.body.data.url).toMatch(
|
||||||
|
/^\/uploads\/\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-image\.webp$/,
|
||||||
|
);
|
||||||
|
expect(response.body.data.hospital.id).toBe(ctx.fixtures.hospitalAId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功:HOSPITAL_ADMIN 可上传视频并转为 mp4', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/uploads')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||||
|
.attach('file', sampleVideoPath, {
|
||||||
|
filename: 'ct-video.mov',
|
||||||
|
contentType: 'video/quicktime',
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 201);
|
||||||
|
expect(response.body.data.type).toBe('VIDEO');
|
||||||
|
expect(response.body.data.mimeType).toBe('video/mp4');
|
||||||
|
expect(response.body.data.fileName).toMatch(/^\d{14}-ct-video\.mp4$/);
|
||||||
|
expect(response.body.data.storagePath).toMatch(
|
||||||
|
/^\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-video\.mp4$/,
|
||||||
|
);
|
||||||
|
expect(response.body.data.url).toMatch(
|
||||||
|
/^\/uploads\/\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-video\.mp4$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败:SYSTEM_ADMIN 上传文件时未指定医院返回 400', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/uploads')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
|
.attach('file', samplePngBuffer, {
|
||||||
|
filename: 'ct-image.png',
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
|
||||||
|
expectErrorEnvelope(response, 400, '系统管理员上传文件时必须显式指定 hospitalId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败:DIRECTOR 查询影像库返回 403', async () => {
|
||||||
|
await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/uploads')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||||
|
.attach('file', samplePngBuffer, {
|
||||||
|
filename: 'seed-image.png',
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/uploads')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
||||||
|
|
||||||
|
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('角色矩阵:上传允许系统/医院/主任/组长/医生,工程师 403,未登录 401', async () => {
|
||||||
|
await assertRoleMatrix({
|
||||||
|
name: 'POST /b/uploads role matrix',
|
||||||
|
tokens: ctx.tokens,
|
||||||
|
expectedStatusByRole: {
|
||||||
|
[Role.SYSTEM_ADMIN]: 400,
|
||||||
|
[Role.HOSPITAL_ADMIN]: 201,
|
||||||
|
[Role.DIRECTOR]: 201,
|
||||||
|
[Role.LEADER]: 201,
|
||||||
|
[Role.DOCTOR]: 201,
|
||||||
|
[Role.ENGINEER]: 403,
|
||||||
|
},
|
||||||
|
sendAsRole: async (role, token) => {
|
||||||
|
const req = request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/uploads')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.attach('file', samplePngBuffer, {
|
||||||
|
filename: `${role.toLowerCase()}.png`,
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (role === Role.SYSTEM_ADMIN) {
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
},
|
||||||
|
sendWithoutToken: async () =>
|
||||||
|
request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/uploads')
|
||||||
|
.attach('file', samplePngBuffer, {
|
||||||
|
filename: 'anonymous.png',
|
||||||
|
contentType: 'image/png',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('角色矩阵:上传列表仅系统/医院管理员可访问,其他角色 403,未登录 401', async () => {
|
||||||
|
await assertRoleMatrix({
|
||||||
|
name: 'GET /b/uploads role matrix',
|
||||||
|
tokens: ctx.tokens,
|
||||||
|
expectedStatusByRole: {
|
||||||
|
[Role.SYSTEM_ADMIN]: 200,
|
||||||
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
|
[Role.DIRECTOR]: 403,
|
||||||
|
[Role.LEADER]: 403,
|
||||||
|
[Role.DOCTOR]: 403,
|
||||||
|
[Role.ENGINEER]: 403,
|
||||||
|
},
|
||||||
|
sendAsRole: async (role, token) => {
|
||||||
|
const req = request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/uploads')
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
if (role === Role.SYSTEM_ADMIN) {
|
||||||
|
req.query({ hospitalId: ctx.fixtures.hospitalAId });
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
},
|
||||||
|
sendWithoutToken: async () => request(ctx.app.getHttpServer()).get('/b/uploads'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createSampleVideo(outputPath: string) {
|
||||||
|
const command = ffmpegPath as unknown as string | null;
|
||||||
|
if (!command) {
|
||||||
|
throw new Error('ffmpeg-static not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-y',
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
'color=c=black:s=320x240:d=1',
|
||||||
|
'-f',
|
||||||
|
'lavfi',
|
||||||
|
'-i',
|
||||||
|
'anullsrc=r=44100:cl=stereo',
|
||||||
|
'-shortest',
|
||||||
|
'-c:v',
|
||||||
|
'libx264',
|
||||||
|
'-pix_fmt',
|
||||||
|
'yuv420p',
|
||||||
|
'-c:a',
|
||||||
|
'aac',
|
||||||
|
'-movflags',
|
||||||
|
'+faststart',
|
||||||
|
outputPath,
|
||||||
|
];
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(command, args);
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
child.stderr?.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr.trim() || 'failed to create sample video'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -43,6 +43,25 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
return response.body.data as { id: number; name: string };
|
return response.body.data as { id: number; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createLeaderUser(token: string) {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/users')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({
|
||||||
|
name: uniqueSeedValue('用户-组长'),
|
||||||
|
phone: uniquePhone(),
|
||||||
|
password: 'Seed@1234',
|
||||||
|
role: Role.LEADER,
|
||||||
|
hospitalId: ctx.fixtures.hospitalAId,
|
||||||
|
departmentId: ctx.fixtures.departmentA1Id,
|
||||||
|
groupId: ctx.fixtures.groupA1Id,
|
||||||
|
openId: uniqueSeedValue('users-leader-openid'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 201);
|
||||||
|
return response.body.data as { id: number; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
async function createEngineerUser(token: string) {
|
async function createEngineerUser(token: string) {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/users')
|
.post('/users')
|
||||||
@ -65,8 +84,12 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
|
await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('成功:DIRECTOR 可创建本科室医生', async () => {
|
it('成功:HOSPITAL_ADMIN 可创建本院医生', async () => {
|
||||||
await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
|
await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功:HOSPITAL_ADMIN 可创建本院组长', async () => {
|
||||||
|
await createLeaderUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:参数校验失败返回 400', async () => {
|
it('失败:参数校验失败返回 400', async () => {
|
||||||
@ -86,31 +109,31 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 400, 'phone 必须是合法手机号');
|
expectErrorEnvelope(response, 400, 'phone 必须是合法手机号');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:DIRECTOR 创建非医生角色返回 403', async () => {
|
it('失败:DIRECTOR 创建用户返回 403', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/users')
|
.post('/users')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
||||||
.send({
|
.send({
|
||||||
name: uniqueSeedValue('主任创建组长'),
|
name: uniqueSeedValue('主任创建用户'),
|
||||||
phone: uniquePhone(),
|
phone: uniquePhone(),
|
||||||
password: 'Seed@1234',
|
password: 'Seed@1234',
|
||||||
role: Role.LEADER,
|
role: Role.DOCTOR,
|
||||||
hospitalId: ctx.fixtures.hospitalAId,
|
hospitalId: ctx.fixtures.hospitalAId,
|
||||||
departmentId: ctx.fixtures.departmentA1Id,
|
departmentId: ctx.fixtures.departmentA1Id,
|
||||||
groupId: ctx.fixtures.groupA1Id,
|
groupId: ctx.fixtures.groupA1Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '当前角色无权限创建该用户');
|
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /users role matrix',
|
name: 'POST /users role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 400,
|
[Role.SYSTEM_ADMIN]: 400,
|
||||||
[Role.HOSPITAL_ADMIN]: 400,
|
[Role.HOSPITAL_ADMIN]: 400,
|
||||||
[Role.DIRECTOR]: 400,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
@ -136,6 +159,34 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expect(Array.isArray(response.body.data)).toBe(true);
|
expect(Array.isArray(response.body.data)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('成功:DIRECTOR 可查看本科室下级列表', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/users')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(Array.isArray(response.body.data)).toBe(true);
|
||||||
|
expect(response.body.data.length).toBeGreaterThan(0);
|
||||||
|
response.body.data.forEach((item: { role: Role; departmentId: number }) => {
|
||||||
|
expect([Role.DOCTOR, Role.LEADER]).toContain(item.role);
|
||||||
|
expect(item.departmentId).toBe(ctx.fixtures.departmentA1Id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功:LEADER 可查看本组医生列表', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/users')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(Array.isArray(response.body.data)).toBe(true);
|
||||||
|
expect(response.body.data.length).toBeGreaterThan(0);
|
||||||
|
response.body.data.forEach((item: { role: Role; groupId: number }) => {
|
||||||
|
expect(item.role).toBe(Role.DOCTOR);
|
||||||
|
expect(item.groupId).toBe(ctx.fixtures.groupA1Id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('失败:未登录返回 401', async () => {
|
it('失败:未登录返回 401', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer()).get('/users');
|
const response = await request(ctx.app.getHttpServer()).get('/users');
|
||||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||||
@ -173,15 +224,6 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
|
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('成功:DIRECTOR 可查询本科室医生详情', async () => {
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.get(`/users/${ctx.fixtures.users.doctorAId}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
|
||||||
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('失败:查询不存在用户返回 404', async () => {
|
it('失败:查询不存在用户返回 404', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.get('/users/99999999')
|
.get('/users/99999999')
|
||||||
@ -190,15 +232,43 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 404, '用户不存在');
|
expectErrorEnvelope(response, 404, '用户不存在');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:DIRECTOR 查询非本科室医生返回 403', async () => {
|
it('成功:DIRECTOR 可查询本科室医生详情', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
|
||||||
|
expect(response.body.data.departmentId).toBe(ctx.fixtures.departmentA1Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功:LEADER 可查询本组医生详情', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
|
||||||
|
expect(response.body.data.groupId).toBe(ctx.fixtures.groupA1Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败:DIRECTOR 查询跨科室医生返回 403', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.get(`/users/${ctx.fixtures.users.doctorA3Id}`)
|
.get(`/users/${ctx.fixtures.users.doctorA3Id}`)
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
|
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可访问,其他角色 403,未登录 401', async () => {
|
it('失败:LEADER 查询其他小组医生返回 403', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get(`/users/${ctx.fixtures.users.doctorA3Id}`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`);
|
||||||
|
|
||||||
|
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'GET /users/:id role matrix',
|
name: 'GET /users/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
@ -206,7 +276,7 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
[Role.SYSTEM_ADMIN]: 200,
|
[Role.SYSTEM_ADMIN]: 200,
|
||||||
[Role.HOSPITAL_ADMIN]: 200,
|
[Role.HOSPITAL_ADMIN]: 200,
|
||||||
[Role.DIRECTOR]: 200,
|
[Role.DIRECTOR]: 200,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 200,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
},
|
},
|
||||||
@ -236,19 +306,31 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expect(response.body.data.name).toBe(nextName);
|
expect(response.body.data.name).toBe(nextName);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('成功:DIRECTOR 可更新本科室医生姓名', async () => {
|
it('成功:HOSPITAL_ADMIN 可更新本院医生姓名', async () => {
|
||||||
const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
|
const created = await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
|
||||||
const nextName = uniqueSeedValue('主任更新医生名');
|
const nextName = uniqueSeedValue('主任更新医生名');
|
||||||
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.patch(`/users/${created.id}`)
|
.patch(`/users/${created.id}`)
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||||
.send({ name: nextName });
|
.send({ name: nextName });
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
expectSuccessEnvelope(response, 200);
|
||||||
expect(response.body.data.name).toBe(nextName);
|
expect(response.body.data.name).toBe(nextName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('成功:HOSPITAL_ADMIN 可将本院医生调整为组长', async () => {
|
||||||
|
const created = await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
|
||||||
|
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.patch(`/users/${created.id}`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||||
|
.send({ role: Role.LEADER, groupId: ctx.fixtures.groupA1Id });
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(response.body.data.role).toBe(Role.LEADER);
|
||||||
|
});
|
||||||
|
|
||||||
it('失败:非医生调整科室/小组返回 400', async () => {
|
it('失败:非医生调整科室/小组返回 400', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.patch(`/users/${ctx.fixtures.users.engineerAId}`)
|
.patch(`/users/${ctx.fixtures.users.engineerAId}`)
|
||||||
@ -265,32 +347,32 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:DIRECTOR 不能把医生改成其他角色', async () => {
|
it('失败:DIRECTOR 更新用户返回 403', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
|
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
||||||
.send({ role: Role.LEADER });
|
.send({ name: uniqueSeedValue('主任越权更新') });
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
|
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:DIRECTOR 不能把医生调整到其他科室', async () => {
|
it('失败:HOSPITAL_ADMIN 不能把用户改成医院管理员', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
|
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||||
.send({ departmentId: ctx.fixtures.departmentA2Id });
|
.send({ role: Role.HOSPITAL_ADMIN });
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
|
expectErrorEnvelope(response, 403, '医院管理员仅可操作本院非管理员账号');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'PATCH /users/:id role matrix',
|
name: 'PATCH /users/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 404,
|
[Role.SYSTEM_ADMIN]: 404,
|
||||||
[Role.HOSPITAL_ADMIN]: 404,
|
[Role.HOSPITAL_ADMIN]: 404,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
@ -319,11 +401,21 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expect(response.body.data.id).toBe(created.id);
|
expect(response.body.data.id).toBe(created.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('成功:DIRECTOR 可删除本科室医生', async () => {
|
it('成功:HOSPITAL_ADMIN 可删除本院医生', async () => {
|
||||||
const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
|
const created = await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.delete(`/users/${created.id}`)
|
.delete(`/users/${created.id}`)
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(response.body.data.id).toBe(created.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功:HOSPITAL_ADMIN 可删除本院组长', async () => {
|
||||||
|
const created = await createLeaderUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.delete(`/users/${created.id}`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
expectSuccessEnvelope(response, 200);
|
||||||
expect(response.body.data.id).toBe(created.id);
|
expect(response.body.data.id).toBe(created.id);
|
||||||
@ -337,30 +429,30 @@ describe('UsersController + BUsersController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除');
|
expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:DIRECTOR 删除非本科室医生返回 403', async () => {
|
it('失败:DIRECTOR 删除用户返回 403', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.delete(`/users/${ctx.fixtures.users.doctorA3Id}`)
|
.delete(`/users/${ctx.fixtures.users.doctorA3Id}`)
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('失败:HOSPITAL_ADMIN 无法删除返回 403', async () => {
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
|
||||||
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:SYSTEM_ADMIN/DIRECTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
it('失败:HOSPITAL_ADMIN 无法删除本院管理员', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.delete(`/users/${ctx.fixtures.users.hospitalAdminAId}`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||||
|
|
||||||
|
expectErrorEnvelope(response, 403, '医院管理员仅可操作本院非管理员账号');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'DELETE /users/:id role matrix',
|
name: 'DELETE /users/:id role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
expectedStatusByRole: {
|
expectedStatusByRole: {
|
||||||
[Role.SYSTEM_ADMIN]: 404,
|
[Role.SYSTEM_ADMIN]: 404,
|
||||||
[Role.HOSPITAL_ADMIN]: 403,
|
[Role.HOSPITAL_ADMIN]: 404,
|
||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 403,
|
||||||
|
|||||||
@ -17,4 +17,5 @@ module.exports = {
|
|||||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
},
|
},
|
||||||
maxWorkers: 1,
|
maxWorkers: 1,
|
||||||
|
testTimeout: 30000,
|
||||||
};
|
};
|
||||||
|
|||||||