新增上传资产模型与迁移,支持 IMAGE、VIDEO、FILE 三类资产管理
新增 B 端上传接口与列表接口,统一文件上传和分页查询能力 上传能力支持医院级数据隔离:系统管理员需显式指定医院,院内角色按登录医院自动隔离 图片上传自动压缩并转为 webp,视频上传自动转码并压缩为 mp4,普通文件按原始类型存储 增加上传目录与公开访问能力,统一输出可直接预览的访问地址 前端新增影像库页面,支持按类型筛选、关键字检索、分页浏览、在线预览与原文件访问 前端新增通用上传组件,支持在页面内复用并返回上传结果 管理后台新增影像库菜单与路由,并补充页面级角色权限控制 患者手术相关表单接入上传复用能力,支持术前资料与设备标签上传回填 新增上传模块 e2e 用例,覆盖成功路径、权限矩阵与关键失败场景 补充上传模块文档与安装依赖说明,完善工程内使用说明
@ -13,20 +13,20 @@
|
||||
|
||||
核心字段:
|
||||
|
||||
- `snCode`:设备唯一标识
|
||||
- `patientId`:归属患者
|
||||
- `surgeryId`:归属手术,可为空
|
||||
- `implantCatalogId`:型号字典 ID,可为空
|
||||
- `implantModel` / `implantManufacturer` / `implantName`:历史快照
|
||||
- `isPressureAdjustable`:是否可调压
|
||||
- `isAbandoned`:是否弃用
|
||||
- `currentPressure`:当前压力
|
||||
- `currentPressure`:当前压力挡位标签
|
||||
- `status`:设备状态
|
||||
|
||||
补充:
|
||||
|
||||
- `currentPressure` 不允许在创建/编辑设备实例时手工指定。
|
||||
- 新植入设备默认以 `initialPressure`(或系统默认值)作为当前压力起点,后续只允许在调压任务完成时更新。
|
||||
- 新植入设备默认以 `initialPressure`(或系统默认值 `0`)作为当前压力起点,后续只允许在调压任务完成时更新。
|
||||
- 发布调压任务时不会立刻修改 `currentPressure`,只有任务完成后才会把目标挡位回写到设备。
|
||||
|
||||
## 3. 植入物目录
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
- `modelCode`:型号编码,唯一
|
||||
- `manufacturer`:厂商
|
||||
- `name`:名称
|
||||
- `pressureLevels`:可调压器械的挡位列表
|
||||
- `pressureLevels`:可调压器械的挡位字符串标签列表
|
||||
- `isPressureAdjustable`:是否可调压
|
||||
- `notes`:目录备注
|
||||
|
||||
@ -45,6 +45,11 @@
|
||||
- 仅 `SYSTEM_ADMIN` 可做目录 CRUD
|
||||
- 目录是全局共享的,不按医院隔离
|
||||
|
||||
说明:
|
||||
|
||||
- 挡位列表按字符串标签保存,例如 `["0.5", "1", "1.5"]` 或 `["10", "20", "30"]`。
|
||||
- 保存前会自动标准化并去重排序,例如 `["01.0", "1.50", "1"]` 最终会整理为 `["1", "1.5"]`。
|
||||
|
||||
## 4. 接口
|
||||
|
||||
设备实例:
|
||||
@ -65,7 +70,6 @@
|
||||
## 5. 约束
|
||||
|
||||
- 设备必须绑定到一个患者。
|
||||
- 设备 SN 在全库唯一,服务端会统一转成大写后再校验。
|
||||
- 删除已被任务明细引用的设备会返回 `409`。
|
||||
- 删除已被患者手术引用的植入物目录会返回 `409`。
|
||||
- 可调压植入物若配置了 `pressureLevels`,患者手术录入和任务调压时的压力值必须命中该挡位列表。
|
||||
|
||||
@ -4,17 +4,17 @@
|
||||
|
||||
- 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。
|
||||
- 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。
|
||||
- 测试前固定执行数据库重置与 seed,确保结果可重复。
|
||||
- 测试前固定执行数据库重置,并通过真实接口全流程建数,确保结果可重复。
|
||||
|
||||
## 2. 风险提示
|
||||
|
||||
`pnpm test:e2e` 会执行:
|
||||
|
||||
1. `prisma migrate reset --force`
|
||||
2. `node prisma/seed.mjs`
|
||||
2. 启动 Jest 后,由测试用例通过真实 HTTP 接口完成基础夹具创建
|
||||
|
||||
这会清空 `.env` 中 `DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
|
||||
另外,seed 账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
|
||||
另外,接口引导创建的测试账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
|
||||
|
||||
## 3. 运行命令
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
仅重置数据库并注入 seed:
|
||||
仅重置数据库并重新生成 Prisma Client:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e:prepare
|
||||
@ -34,7 +34,7 @@ pnpm test:e2e:prepare
|
||||
pnpm test:e2e:watch
|
||||
```
|
||||
|
||||
## 4. 种子账号(默认密码:`Seed@1234`)
|
||||
## 4. 接口引导夹具(默认密码:`Seed@1234`)
|
||||
|
||||
- 系统管理员:`13800001000`
|
||||
- 院管(医院 A):`13800001001`
|
||||
@ -42,20 +42,37 @@ pnpm test:e2e:watch
|
||||
- 组长(医院 A):`13800001003`
|
||||
- 医生(医院 A):`13800001004`
|
||||
- 工程师(医院 A):`13800001005`
|
||||
- 院管(医院 B):`13800001011`
|
||||
- 工程师(医院 B):`13800001015`
|
||||
|
||||
说明:
|
||||
|
||||
- 这些账号不再由 `prisma/seed.mjs` 直写生成。
|
||||
- 每次执行 E2E 时,会先创建系统管理员,再通过后台接口依次创建医院、院管、医生、工程师、目录、患者、手术和调压任务。
|
||||
- 因为夹具是通过真实业务接口生成的,所以权限、作用域、删除保护和调压链路都能在同一套测试里被覆盖。
|
||||
|
||||
## 5. 用例结构
|
||||
|
||||
- `test/e2e/specs/auth.e2e-spec.ts`
|
||||
- `test/e2e/specs/users.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/patients.e2e-spec.ts`
|
||||
- `test/e2e/specs/auth-token-revocation.e2e-spec.ts`
|
||||
|
||||
## 6. 覆盖策略
|
||||
|
||||
- 受保护接口(27 个):每个接口覆盖 6 角色访问结果 + 未登录 401。
|
||||
- 非受保护接口(3 个):每个接口至少 1 个成功 + 1 个失败。
|
||||
- 关键行为额外覆盖:
|
||||
- 从创建系统管理员开始的完整接口建数链路
|
||||
- 任务状态机冲突(409)
|
||||
- 调压任务发布后不改当前压力,完成任务后才回写设备当前压力
|
||||
- 主刀医生自动跟随患者归属医生,且历史手术保留快照
|
||||
- 患者 B 端角色可见性
|
||||
- 患者创建人返回与展示
|
||||
- 跨院工程师隔离
|
||||
- 组织域院管作用域限制与删除冲突
|
||||
- 目录、设备、组织、用户的删除保护
|
||||
|
||||
@ -5,10 +5,11 @@
|
||||
- 登录页:`/auth/login`,支持可选 `hospitalId`。
|
||||
- 首页看板:按角色拉取组织与患者统计。
|
||||
- 设备页:新增管理员专用设备 CRUD,复用真实设备接口。
|
||||
- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。
|
||||
- 任务页:改为只读调压记录页,接入真实任务列表接口。
|
||||
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
|
||||
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`),
|
||||
后端直接保存身份证号原文,不再做哈希转换。
|
||||
后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。
|
||||
- 新增影像库页:接入真实上传接口,支持图片/视频/文件上传与分页查看。
|
||||
|
||||
## 2. 接口契约对齐点
|
||||
|
||||
@ -16,23 +17,27 @@
|
||||
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
|
||||
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
|
||||
- `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`。
|
||||
- 患者表单中的 `idCard` 字段直接传身份证号;
|
||||
服务端只会做去空格与 `x/X` 标准化,不会转哈希。
|
||||
- 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。
|
||||
- 患者手术、调压任务、设备目录中的压力值全部按字符串挡位标签传输,例如 `0.5`、`1`、`1.5`、`10`。
|
||||
|
||||
## 3. 角色权限提示
|
||||
|
||||
- 任务接口权限:
|
||||
- `DOCTOR/DIRECTOR/LEADER`:发布、取消(仅可取消自己创建的任务)
|
||||
- `ENGINEER`:接收、完成
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时必须指定接收工程师;可取消自己创建的任务
|
||||
- `ENGINEER`:仅可完成分配给自己的任务
|
||||
- 患者列表权限:
|
||||
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
||||
- 用户管理接口:
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR` 可访问列表与创建
|
||||
- `DIRECTOR` 页面语义调整为“医生管理”,仅管理本科室医生
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可创建、编辑、删除
|
||||
- `DIRECTOR` 可只读查看本科室下级医生/组长
|
||||
- 工程师绑定医院仅 `SYSTEM_ADMIN`
|
||||
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`DIRECTOR` 可删除本科室无关联医生
|
||||
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`HOSPITAL_ADMIN` 可删除本院无关联、且非管理员用户
|
||||
|
||||
## 3.1 结构图页面交互调整
|
||||
|
||||
@ -46,27 +51,29 @@
|
||||
`SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问
|
||||
- `organization/departments`:
|
||||
仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
||||
- `users`:`SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR` 可访问
|
||||
- `users`:`SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可管理;`DIRECTOR` 可只读查看
|
||||
- `devices`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
||||
- `uploads`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
||||
- `organization/hospitals`
|
||||
- 仅 `SYSTEM_ADMIN` 可访问
|
||||
- `tasks`
|
||||
- 仅 `DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问
|
||||
- `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问
|
||||
- `patients`
|
||||
- `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问
|
||||
|
||||
患者页负责发起调压任务并指定接收人,任务页仅查看调压记录,不再提供发布或接收入口。
|
||||
|
||||
患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。
|
||||
|
||||
前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。
|
||||
|
||||
## 3.3 主任/组长组织管理范围
|
||||
|
||||
- `DIRECTOR`
|
||||
- 可查看组织架构、小组列表(限定本科室范围)
|
||||
- 可创建/编辑/删除本科室下小组
|
||||
- 可进入“医生管理”页,创建/维护本科室医生
|
||||
- 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
|
||||
- `LEADER`
|
||||
- 可查看组织架构、小组列表(限定本科室/本小组范围)
|
||||
- 可编辑本小组名称
|
||||
- 主任/组长不再显示独立“科室管理”页面。
|
||||
- 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
|
||||
- 主任/组长不再显示“科室管理”“小组管理”“用户管理”页面。
|
||||
- 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。
|
||||
|
||||
## 4. 本地运行
|
||||
|
||||
@ -29,7 +29,8 @@
|
||||
|
||||
- `surgeryDate`:手术日期
|
||||
- `surgeryName`:手术名称
|
||||
- `surgeonName`:主刀医生
|
||||
- `surgeonId`:主刀医生账号 ID(自动等于患者归属医生)
|
||||
- `surgeonName`:主刀医生姓名快照
|
||||
- `preOpPressure`:术前测压,可为空
|
||||
- `primaryDisease`:原发病
|
||||
- `hydrocephalusTypes`:脑积水类型,多选
|
||||
@ -43,6 +44,12 @@
|
||||
- `activeDeviceCount`:本次手术仍在用设备数
|
||||
- `abandonedDeviceCount`:本次手术已弃用设备数
|
||||
|
||||
说明:
|
||||
|
||||
- 新增/修改手术时,前端不再单独提交主刀医生。
|
||||
- 后端会直接使用患者当前的 `doctorId` 作为 `surgeonId`,并保存当时的 `surgeonName` 快照。
|
||||
- 如果后续患者归属医生发生变化,只会影响后续新建手术;历史手术仍保留创建当时的主刀医生快照。
|
||||
|
||||
## 4. 植入设备
|
||||
|
||||
设备表仍沿用 `Device`,但语义改为“患者手术下植入的设备实例”。
|
||||
@ -57,7 +64,7 @@
|
||||
- `proximalPunctureAreas`:近端穿刺区域,最多 2 个
|
||||
- `valvePlacementSites`:阀门植入部位,最多 2 个
|
||||
- `distalShuntDirection`:远端分流方向
|
||||
- `initialPressure`:初始压力,可为空
|
||||
- `initialPressure`:初始压力挡位标签,可为空
|
||||
- `implantNotes`:植入物备注,可为空
|
||||
- `labelImageUrl`:植入物标签图片地址,可为空
|
||||
|
||||
@ -68,8 +75,10 @@
|
||||
- 旧设备弃用后,`TaskItem` 历史不会删除。
|
||||
- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。
|
||||
- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。
|
||||
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的值。
|
||||
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`,后续仅能由调压任务完成后更新。
|
||||
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的字符串标签值,例如 `0.5 / 1 / 1.5 / 10 / 20 / 30`。
|
||||
- 挡位标签保存前会自动标准化,例如 `01.0 -> 1`、`1.50 -> 1.5`。
|
||||
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`;发布调压任务时不会立刻改当前压力,只有工程师完成任务后才会更新。
|
||||
- 术前资料与植入物标签现在支持直接上传;上传成功后,患者详情会按图片/视频做预览,不再只是裸链接。
|
||||
|
||||
## 5. B 端可见性
|
||||
|
||||
@ -93,6 +102,7 @@
|
||||
|
||||
- `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。
|
||||
- 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`。
|
||||
- 患者建档时会自动记录 `creator`,列表和详情都会返回创建人信息。
|
||||
|
||||
## 7. C 端生命周期聚合
|
||||
|
||||
@ -111,6 +121,10 @@
|
||||
- `SURGERY`
|
||||
- `TASK_PRESSURE_ADJUSTMENT`
|
||||
|
||||
说明:
|
||||
|
||||
- 生命周期中的 `initialPressure / currentPressure / oldPressure / targetPressure` 均返回字符串挡位标签。
|
||||
|
||||
## 8. 响应结构
|
||||
|
||||
全部接口统一返回:
|
||||
|
||||
@ -7,34 +7,47 @@
|
||||
|
||||
## 2. 状态机
|
||||
|
||||
- `PENDING -> ACCEPTED -> COMPLETED`
|
||||
- `PENDING/ACCEPTED -> CANCELLED`
|
||||
- 当前发布流程:`ACCEPTED -> COMPLETED`
|
||||
- 当前取消流程:`ACCEPTED -> CANCELLED`
|
||||
- 历史兼容:保留 `PENDING` 状态枚举,便于兼容旧数据与旧任务记录
|
||||
|
||||
非法流转会返回 `409` 冲突错误(中文消息)。
|
||||
|
||||
## 3. 角色权限
|
||||
|
||||
- 医生/主任/组长:发布任务、取消自己创建的任务
|
||||
- 工程师:接收任务、完成自己接收的任务
|
||||
- 系统管理员/医院管理员/医生/主任/组长:发布任务时必须直接指定接收工程师,并且只能取消自己创建的任务
|
||||
- 工程师:不能再执行“接收”动作,只能完成指派给自己的任务
|
||||
- 其他角色:默认拒绝
|
||||
|
||||
补充:
|
||||
|
||||
- `GET /b/tasks/engineers`:返回当前角色可选的接收工程师列表,系统管理员可按医院筛选。
|
||||
- `GET /b/tasks`:返回当前角色可见的调压记录列表,系统管理员可按医院筛选。
|
||||
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
|
||||
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
|
||||
|
||||
## 4. 事件触发
|
||||
## 4. 记录列表
|
||||
|
||||
- 后台任务页不再承担手工发布入口,只展示调压记录。
|
||||
- 记录维度按 `TaskItem` 展开,每条记录会携带:
|
||||
- 任务状态
|
||||
- 患者信息
|
||||
- 手术名称
|
||||
- 设备信息
|
||||
- 旧压力 / 目标压力 / 当前压力(均为字符串挡位标签)
|
||||
- 创建人 / 接收人 / 发布时间
|
||||
|
||||
## 5. 事件触发
|
||||
|
||||
状态变化后会发出事件:
|
||||
|
||||
- `task.published`
|
||||
- `task.accepted`
|
||||
- `task.completed`
|
||||
- `task.cancelled`
|
||||
|
||||
用于后续接入微信通知或消息中心。
|
||||
|
||||
## 5. 完成任务时的设备同步
|
||||
## 6. 完成任务时的设备同步
|
||||
|
||||
`completeTask` 在单事务中执行:
|
||||
|
||||
@ -43,3 +56,8 @@
|
||||
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` 强隔离。
|
||||
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
|
||||
- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。
|
||||
- `DIRECTOR` 可创建、查看、编辑、删除本科室医生,但不能跨科室操作,也不能把医生改成其他角色。
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可执行用户创建、编辑、删除。
|
||||
- `DIRECTOR` 仅可只读查看本科室下级医生/组长。
|
||||
- `LEADER` 仅可只读查看本小组医生列表。
|
||||
- `HOSPITAL_ADMIN` 仅可操作本院非管理员账号。
|
||||
- 用户组织字段校验:
|
||||
- 院管/医生/工程师等需有医院归属;
|
||||
- 主任/组长需有科室/小组等必要归属;
|
||||
@ -32,12 +34,22 @@
|
||||
- `GET /users`、`GET /users/:id`、`PATCH /users/:id`、`DELETE /users/:id`
|
||||
- `POST /b/users/:id/assign-engineer-hospital`
|
||||
|
||||
其中院管侧的常用链路为:
|
||||
|
||||
- `POST /users`:创建本院用户
|
||||
- `GET /users/:id`:查看本院用户详情
|
||||
- `PATCH /users/:id`:修改本院用户信息
|
||||
- `DELETE /users/:id`:删除本院无关联、且非管理员用户
|
||||
|
||||
其中主任侧的常用链路为:
|
||||
|
||||
- `POST /users`:创建本科室医生
|
||||
- `GET /users/:id`:查看本科室医生详情
|
||||
- `PATCH /users/:id`:修改本科室医生信息
|
||||
- `DELETE /users/:id`:删除无关联数据的本科室医生
|
||||
- `GET /users`:查看本科室下级医生/组长
|
||||
- `GET /users/:id`:查看本科室下级详情
|
||||
|
||||
其中组长侧的常用链路为:
|
||||
|
||||
- `GET /users`:查看本小组医生列表
|
||||
- `GET /users/:id`:查看本小组医生详情
|
||||
|
||||
## 5. 开发改造建议
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"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: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-validator": "^0.15.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.1.1",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -44,6 +47,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"globals": "^16.0.0",
|
||||
|
||||
388
pnpm-lock.yaml
generated
@ -44,9 +44,15 @@ importers:
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.3.1
|
||||
ffmpeg-static:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.3
|
||||
multer:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
pg:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
@ -56,6 +62,9 @@ importers:
|
||||
rxjs:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.2
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
swagger-ui-express:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1(express@5.2.1)
|
||||
@ -81,6 +90,9 @@ importers:
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.10
|
||||
version: 9.0.10
|
||||
'@types/multer':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
'@types/node':
|
||||
specifier: ^22.10.7
|
||||
version: 22.19.15
|
||||
@ -342,6 +354,10 @@ packages:
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
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':
|
||||
resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==}
|
||||
hasBin: true
|
||||
@ -371,6 +387,159 @@ packages:
|
||||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
|
||||
engines: {node: '>=18'}
|
||||
@ -932,6 +1101,12 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
||||
|
||||
@ -1147,6 +1322,10 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
ajv-formats@2.1.1:
|
||||
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
||||
peerDependencies:
|
||||
@ -1368,6 +1547,9 @@ packages:
|
||||
caniuse-lite@1.0.30001778:
|
||||
resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==}
|
||||
|
||||
caseless@0.12.0:
|
||||
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@ -1573,6 +1755,10 @@ packages:
|
||||
destr@2.0.5:
|
||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
detect-newline@3.1.0:
|
||||
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
|
||||
engines: {node: '>=8'}
|
||||
@ -1633,6 +1819,10 @@ packages:
|
||||
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
env-paths@2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
@ -1736,6 +1926,10 @@ packages:
|
||||
fb-watchman@2.0.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
|
||||
engines: {node: '>=20'}
|
||||
@ -1896,9 +2090,16 @@ packages:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-response-object@3.0.2:
|
||||
resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
|
||||
|
||||
http-status-codes@2.3.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
engines: {node: '>=10.17.0'}
|
||||
@ -2465,6 +2666,9 @@ packages:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parse-cache-control@1.0.1:
|
||||
resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==}
|
||||
|
||||
parse-json@5.2.0:
|
||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||
engines: {node: '>=8'}
|
||||
@ -2639,6 +2843,10 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
progress@2.0.3:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
proper-lockfile@4.1.2:
|
||||
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
|
||||
|
||||
@ -2778,6 +2986,10 @@ packages:
|
||||
setprototypeof@1.2.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@ -3491,6 +3703,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@electric-sql/pglite': 0.3.15
|
||||
@ -3521,6 +3740,102 @@ snapshots:
|
||||
dependencies:
|
||||
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/checkbox@4.3.2(@types/node@22.19.15)':
|
||||
@ -4247,6 +4562,12 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@ -4452,6 +4773,12 @@ snapshots:
|
||||
|
||||
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):
|
||||
optionalDependencies:
|
||||
ajv: 8.18.0
|
||||
@ -4699,6 +5026,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001778: {}
|
||||
|
||||
caseless@0.12.0: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@ -4864,6 +5193,8 @@ snapshots:
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
detect-newline@3.1.0: {}
|
||||
|
||||
dezalgo@1.0.4:
|
||||
@ -4913,6 +5244,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.0
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
@ -5035,6 +5368,15 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
dependencies:
|
||||
'@tokenizer/inflate': 0.4.1
|
||||
@ -5229,8 +5571,19 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-response-object@3.0.2:
|
||||
dependencies:
|
||||
'@types/node': 10.17.60
|
||||
|
||||
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: {}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
@ -5926,6 +6279,8 @@ snapshots:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
parse-cache-control@1.0.1: {}
|
||||
|
||||
parse-json@5.2.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
@ -6076,6 +6431,8 @@ snapshots:
|
||||
- react
|
||||
- react-dom
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
proper-lockfile@4.1.2:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@ -6223,6 +6580,37 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
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
|
||||
}
|
||||
|
||||
// 上传资产类型:用于图库/视频库分类。
|
||||
enum UploadAssetType {
|
||||
IMAGE
|
||||
VIDEO
|
||||
FILE
|
||||
}
|
||||
|
||||
// 医院主表:多租户顶层实体。
|
||||
model Hospital {
|
||||
id Int @id @default(autoincrement())
|
||||
@ -54,6 +61,7 @@ model Hospital {
|
||||
users User[]
|
||||
patients Patient[]
|
||||
tasks Task[]
|
||||
uploads UploadAsset[]
|
||||
}
|
||||
|
||||
// 科室表:归属于医院。
|
||||
@ -81,25 +89,28 @@ model Group {
|
||||
|
||||
// 用户表:支持后台密码登录与小程序 openId。
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
phone String
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
phone String
|
||||
// 后台登录密码哈希(bcrypt)。
|
||||
passwordHash String?
|
||||
passwordHash String?
|
||||
// 该时间点之前签发的 token 一律失效。
|
||||
tokenValidAfter DateTime @default(now())
|
||||
openId String? @unique
|
||||
role Role
|
||||
hospitalId Int?
|
||||
departmentId Int?
|
||||
groupId Int?
|
||||
hospital Hospital? @relation(fields: [hospitalId], references: [id])
|
||||
department Department? @relation(fields: [departmentId], references: [id])
|
||||
tokenValidAfter DateTime @default(now())
|
||||
openId String? @unique
|
||||
role Role
|
||||
hospitalId Int?
|
||||
departmentId Int?
|
||||
groupId Int?
|
||||
hospital Hospital? @relation(fields: [hospitalId], references: [id])
|
||||
department Department? @relation(fields: [departmentId], references: [id])
|
||||
// 小组删除必须先清理成员,避免静默把用户 groupId 置空。
|
||||
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
|
||||
doctorPatients Patient[] @relation("DoctorPatients")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
acceptedTasks Task[] @relation("TaskEngineer")
|
||||
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
|
||||
doctorPatients Patient[] @relation("DoctorPatients")
|
||||
createdPatients Patient[] @relation("PatientCreator")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
acceptedTasks Task[] @relation("TaskEngineer")
|
||||
surgeonSurgeries PatientSurgery[] @relation("SurgerySurgeon")
|
||||
createdUploads UploadAsset[] @relation("UploadCreator")
|
||||
|
||||
@@unique([phone, role, hospitalId])
|
||||
@@index([phone])
|
||||
@ -112,6 +123,7 @@ model User {
|
||||
model Patient {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
// 住院号:用于院内患者检索与病案关联。
|
||||
inpatientNo String?
|
||||
// 项目名称:用于区分患者所属项目/课题。
|
||||
@ -121,14 +133,17 @@ model Patient {
|
||||
idCard String
|
||||
hospitalId Int
|
||||
doctorId Int
|
||||
creatorId Int
|
||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
|
||||
creator User @relation("PatientCreator", fields: [creatorId], references: [id])
|
||||
surgeries PatientSurgery[]
|
||||
devices Device[]
|
||||
|
||||
@@index([phone, idCard])
|
||||
@@index([hospitalId, doctorId])
|
||||
@@index([inpatientNo])
|
||||
@@index([creatorId])
|
||||
}
|
||||
|
||||
// 患者手术表:保存每次分流/复手术档案。
|
||||
@ -137,6 +152,7 @@ model PatientSurgery {
|
||||
patientId Int
|
||||
surgeryDate DateTime
|
||||
surgeryName String
|
||||
surgeonId Int?
|
||||
surgeonName String
|
||||
// 术前测压:部分患者可为空。
|
||||
preOpPressure Int?
|
||||
@ -151,9 +167,11 @@ model PatientSurgery {
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||
surgeon User? @relation("SurgerySurgeon", fields: [surgeonId], references: [id], onDelete: SetNull)
|
||||
devices Device[]
|
||||
|
||||
@@index([patientId, surgeryDate])
|
||||
@@index([surgeonId])
|
||||
}
|
||||
|
||||
// 植入物型号字典:供前端单选型号后自动回填厂家与名称。
|
||||
@ -163,7 +181,7 @@ model ImplantCatalog {
|
||||
manufacturer String
|
||||
name String
|
||||
// 可调压器械的可选挡位,由系统管理员维护。
|
||||
pressureLevels Int[] @default([])
|
||||
pressureLevels String[] @default([])
|
||||
isPressureAdjustable Boolean @default(true)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
@ -185,11 +203,30 @@ model DictionaryItem {
|
||||
@@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 {
|
||||
id Int @id @default(autoincrement())
|
||||
snCode String @unique
|
||||
currentPressure Int
|
||||
currentPressure String
|
||||
status DeviceStatus @default(ACTIVE)
|
||||
patientId Int
|
||||
surgeryId Int?
|
||||
@ -205,7 +242,7 @@ model Device {
|
||||
proximalPunctureAreas String[] @default([])
|
||||
valvePlacementSites String[] @default([])
|
||||
distalShuntDirection String?
|
||||
initialPressure Int?
|
||||
initialPressure String?
|
||||
implantNotes String?
|
||||
labelImageUrl String?
|
||||
patient Patient @relation(fields: [patientId], references: [id])
|
||||
@ -240,8 +277,8 @@ model TaskItem {
|
||||
id Int @id @default(autoincrement())
|
||||
taskId Int
|
||||
deviceId Int
|
||||
oldPressure Int
|
||||
targetPressure Int
|
||||
oldPressure String
|
||||
targetPressure String
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
device Device @relation(fields: [deviceId], references: [id])
|
||||
|
||||
|
||||
361
prisma/seed.mjs
@ -64,6 +64,7 @@ async function upsertUserByOpenId(openId, data) {
|
||||
async function ensurePatient({
|
||||
hospitalId,
|
||||
doctorId,
|
||||
creatorId,
|
||||
name,
|
||||
inpatientNo = null,
|
||||
projectName = null,
|
||||
@ -81,13 +82,14 @@ async function ensurePatient({
|
||||
if (existing) {
|
||||
if (
|
||||
existing.doctorId !== doctorId ||
|
||||
existing.creatorId !== creatorId ||
|
||||
existing.name !== name ||
|
||||
existing.inpatientNo !== inpatientNo ||
|
||||
existing.projectName !== projectName
|
||||
) {
|
||||
return prisma.patient.update({
|
||||
where: { id: existing.id },
|
||||
data: { doctorId, name, inpatientNo, projectName },
|
||||
data: { doctorId, creatorId, name, inpatientNo, projectName },
|
||||
});
|
||||
}
|
||||
return existing;
|
||||
@ -97,6 +99,7 @@ async function ensurePatient({
|
||||
data: {
|
||||
hospitalId,
|
||||
doctorId,
|
||||
creatorId,
|
||||
name,
|
||||
inpatientNo,
|
||||
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() {
|
||||
const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12);
|
||||
|
||||
@ -395,6 +455,7 @@ async function main() {
|
||||
const patientA1 = await ensurePatient({
|
||||
hospitalId: hospitalA.id,
|
||||
doctorId: doctorA.id,
|
||||
creatorId: doctorA.id,
|
||||
name: 'Seed Patient A1',
|
||||
inpatientNo: 'ZYH-A-0001',
|
||||
projectName: '脑积水随访项目-A',
|
||||
@ -405,6 +466,7 @@ async function main() {
|
||||
const patientA2 = await ensurePatient({
|
||||
hospitalId: hospitalA.id,
|
||||
doctorId: doctorA2.id,
|
||||
creatorId: doctorA2.id,
|
||||
name: 'Seed Patient A2',
|
||||
inpatientNo: 'ZYH-A-0002',
|
||||
projectName: '脑积水随访项目-A',
|
||||
@ -415,6 +477,7 @@ async function main() {
|
||||
const patientA3 = await ensurePatient({
|
||||
hospitalId: hospitalA.id,
|
||||
doctorId: doctorA3.id,
|
||||
creatorId: doctorA3.id,
|
||||
name: 'Seed Patient A3',
|
||||
inpatientNo: 'ZYH-A-0003',
|
||||
projectName: '脑积水随访项目-A',
|
||||
@ -425,6 +488,7 @@ async function main() {
|
||||
const patientB1 = await ensurePatient({
|
||||
hospitalId: hospitalB.id,
|
||||
doctorId: doctorB.id,
|
||||
creatorId: doctorB.id,
|
||||
name: 'Seed Patient B1',
|
||||
inpatientNo: 'ZYH-B-0001',
|
||||
projectName: '脑积水随访项目-B',
|
||||
@ -510,219 +574,104 @@ async function main() {
|
||||
hydrocephalusTypes: ['高压性'],
|
||||
});
|
||||
|
||||
const deviceA1 = await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-A-001' },
|
||||
update: {
|
||||
patientId: patientA1.id,
|
||||
surgeryId: surgeryA1New.id,
|
||||
implantCatalogId: adjustableCatalog.id,
|
||||
currentPressure: 118,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
implantModel: adjustableCatalog.modelCode,
|
||||
implantManufacturer: adjustableCatalog.manufacturer,
|
||||
implantName: adjustableCatalog.name,
|
||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||
isAbandoned: false,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['额角'],
|
||||
valvePlacementSites: ['耳后'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 118,
|
||||
implantNotes: 'Seed A1 当前在用设备',
|
||||
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-001',
|
||||
patientId: patientA1.id,
|
||||
surgeryId: surgeryA1New.id,
|
||||
implantCatalogId: adjustableCatalog.id,
|
||||
currentPressure: 118,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
implantModel: adjustableCatalog.modelCode,
|
||||
implantManufacturer: adjustableCatalog.manufacturer,
|
||||
implantName: adjustableCatalog.name,
|
||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||
isAbandoned: false,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['额角'],
|
||||
valvePlacementSites: ['耳后'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 118,
|
||||
implantNotes: 'Seed A1 当前在用设备',
|
||||
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
|
||||
},
|
||||
const deviceA1 = await ensureDevice({
|
||||
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({
|
||||
where: { snCode: 'SEED-SN-A-002' },
|
||||
update: {
|
||||
patientId: patientA2.id,
|
||||
surgeryId: surgeryA2.id,
|
||||
implantCatalogId: adjustableCatalog.id,
|
||||
currentPressure: 112,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
implantModel: adjustableCatalog.modelCode,
|
||||
implantManufacturer: adjustableCatalog.manufacturer,
|
||||
implantName: adjustableCatalog.name,
|
||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||
isAbandoned: false,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['枕角'],
|
||||
valvePlacementSites: ['胸前'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 112,
|
||||
implantNotes: 'Seed A2 当前在用设备',
|
||||
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-002',
|
||||
patientId: patientA2.id,
|
||||
surgeryId: surgeryA2.id,
|
||||
implantCatalogId: adjustableCatalog.id,
|
||||
currentPressure: 112,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
implantModel: adjustableCatalog.modelCode,
|
||||
implantManufacturer: adjustableCatalog.manufacturer,
|
||||
implantName: adjustableCatalog.name,
|
||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||
isAbandoned: false,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['枕角'],
|
||||
valvePlacementSites: ['胸前'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 112,
|
||||
implantNotes: 'Seed A2 当前在用设备',
|
||||
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
|
||||
},
|
||||
const deviceA2 = await ensureDevice({
|
||||
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({
|
||||
where: { snCode: 'SEED-SN-A-003' },
|
||||
update: {
|
||||
patientId: patientA3.id,
|
||||
surgeryId: surgeryA3.id,
|
||||
implantCatalogId: adjustableCatalog.id,
|
||||
currentPressure: 109,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
implantModel: adjustableCatalog.modelCode,
|
||||
implantManufacturer: adjustableCatalog.manufacturer,
|
||||
implantName: adjustableCatalog.name,
|
||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||
isAbandoned: false,
|
||||
shuntMode: 'LPS',
|
||||
proximalPunctureAreas: ['腰穿'],
|
||||
valvePlacementSites: ['腰背部'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 109,
|
||||
implantNotes: 'Seed A3 当前在用设备',
|
||||
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-003',
|
||||
patientId: patientA3.id,
|
||||
surgeryId: surgeryA3.id,
|
||||
implantCatalogId: adjustableCatalog.id,
|
||||
currentPressure: 109,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
implantModel: adjustableCatalog.modelCode,
|
||||
implantManufacturer: adjustableCatalog.manufacturer,
|
||||
implantName: adjustableCatalog.name,
|
||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||
isAbandoned: false,
|
||||
shuntMode: 'LPS',
|
||||
proximalPunctureAreas: ['腰穿'],
|
||||
valvePlacementSites: ['腰背部'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 109,
|
||||
implantNotes: 'Seed A3 当前在用设备',
|
||||
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
|
||||
},
|
||||
await ensureDevice({
|
||||
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({
|
||||
where: { snCode: 'SEED-SN-B-001' },
|
||||
update: {
|
||||
patientId: patientB1.id,
|
||||
surgeryId: surgeryB1.id,
|
||||
implantCatalogId: adjustableCatalog.id,
|
||||
currentPressure: 121,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
implantModel: adjustableCatalog.modelCode,
|
||||
implantManufacturer: adjustableCatalog.manufacturer,
|
||||
implantName: adjustableCatalog.name,
|
||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||
isAbandoned: false,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['额角'],
|
||||
valvePlacementSites: ['耳后'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 121,
|
||||
implantNotes: 'Seed B1 当前在用设备',
|
||||
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-B-001',
|
||||
patientId: patientB1.id,
|
||||
surgeryId: surgeryB1.id,
|
||||
implantCatalogId: adjustableCatalog.id,
|
||||
currentPressure: 121,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
implantModel: adjustableCatalog.modelCode,
|
||||
implantManufacturer: adjustableCatalog.manufacturer,
|
||||
implantName: adjustableCatalog.name,
|
||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||
isAbandoned: false,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['额角'],
|
||||
valvePlacementSites: ['耳后'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 121,
|
||||
implantNotes: 'Seed B1 当前在用设备',
|
||||
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
|
||||
},
|
||||
const deviceB1 = await ensureDevice({
|
||||
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({
|
||||
where: { snCode: 'SEED-SN-A-004' },
|
||||
update: {
|
||||
patientId: patientA1.id,
|
||||
surgeryId: surgeryA1Old.id,
|
||||
implantCatalogId: adjustableCatalog.id,
|
||||
currentPressure: 130,
|
||||
status: DeviceStatus.INACTIVE,
|
||||
implantModel: adjustableCatalog.modelCode,
|
||||
implantManufacturer: adjustableCatalog.manufacturer,
|
||||
implantName: adjustableCatalog.name,
|
||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||
isAbandoned: true,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['额角'],
|
||||
valvePlacementSites: ['耳后'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 130,
|
||||
implantNotes: 'Seed A1 弃用历史设备',
|
||||
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-004',
|
||||
patientId: patientA1.id,
|
||||
surgeryId: surgeryA1Old.id,
|
||||
implantCatalogId: adjustableCatalog.id,
|
||||
currentPressure: 130,
|
||||
status: DeviceStatus.INACTIVE,
|
||||
implantModel: adjustableCatalog.modelCode,
|
||||
implantManufacturer: adjustableCatalog.manufacturer,
|
||||
implantName: adjustableCatalog.name,
|
||||
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||
isAbandoned: true,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['额角'],
|
||||
valvePlacementSites: ['耳后'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 130,
|
||||
implantNotes: 'Seed A1 弃用历史设备',
|
||||
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
|
||||
},
|
||||
await ensureDevice({
|
||||
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 可重复执行且生命周期夹具稳定。
|
||||
|
||||
@ -9,6 +9,7 @@ import { OrganizationModule } from './organization/organization.module.js';
|
||||
import { NotificationsModule } from './notifications/notifications.module.js';
|
||||
import { DevicesModule } from './devices/devices.module.js';
|
||||
import { DictionariesModule } from './dictionaries/dictionaries.module.js';
|
||||
import { UploadsModule } from './uploads/uploads.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -22,6 +23,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module.js';
|
||||
NotificationsModule,
|
||||
DevicesModule,
|
||||
DictionariesModule,
|
||||
UploadsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -58,19 +58,22 @@ export const MESSAGES = {
|
||||
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
|
||||
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
|
||||
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
|
||||
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生账号',
|
||||
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生或组长账号',
|
||||
},
|
||||
|
||||
TASK: {
|
||||
ITEMS_REQUIRED: '任务明细 items 不能为空',
|
||||
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
|
||||
DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院',
|
||||
ENGINEER_REQUIRED: '接收工程师必选',
|
||||
ENGINEER_INVALID: '工程师必须为当前医院有效工程师',
|
||||
TASK_NOT_FOUND: '任务不存在或不属于当前医院',
|
||||
ACCEPT_ONLY_PENDING: '仅待接收任务可执行接收',
|
||||
COMPLETE_ONLY_ACCEPTED: '仅已接收任务可执行完成',
|
||||
CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消',
|
||||
ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收',
|
||||
ENGINEER_ONLY_ASSIGNEE: '仅任务接收工程师可完成任务',
|
||||
ACCEPT_DISABLED: '当前流程不支持工程师接收,请由创建人直接指定接收工程师',
|
||||
ACCEPT_ONLY_PENDING: '仅待指派任务可执行接收',
|
||||
COMPLETE_ONLY_ACCEPTED: '仅已指派任务可执行完成',
|
||||
CANCEL_ONLY_PENDING_ACCEPTED: '仅待指派/已指派任务可取消',
|
||||
ENGINEER_ALREADY_ASSIGNED: '任务已指派给其他工程师',
|
||||
ENGINEER_ONLY_ASSIGNEE: '仅任务接收人可完成任务',
|
||||
CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务',
|
||||
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
|
||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||
@ -99,9 +102,7 @@ export const MESSAGES = {
|
||||
|
||||
DEVICE: {
|
||||
NOT_FOUND: '设备不存在或无权限访问',
|
||||
SN_CODE_REQUIRED: 'snCode 不能为空',
|
||||
SN_CODE_DUPLICATE: '设备 SN 已存在',
|
||||
CURRENT_PRESSURE_INVALID: 'currentPressure 必须为大于等于 0 的整数',
|
||||
CURRENT_PRESSURE_INVALID: 'currentPressure 必须是合法挡位标签',
|
||||
STATUS_INVALID: '设备状态不合法',
|
||||
PATIENT_REQUIRED: 'patientId 必填且必须为整数',
|
||||
PATIENT_NOT_FOUND: '归属患者不存在',
|
||||
@ -123,6 +124,17 @@ export const MESSAGES = {
|
||||
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: {
|
||||
HOSPITAL_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()
|
||||
@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: '查询科室列表' })
|
||||
@ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' })
|
||||
findAll(
|
||||
@ -69,7 +75,13 @@ export class DepartmentsController {
|
||||
* 查询科室详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '查询科室详情' })
|
||||
@ApiParam({ name: 'id', description: '科室 ID' })
|
||||
findOne(
|
||||
@ -83,7 +95,7 @@ export class DepartmentsController {
|
||||
* 更新科室。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '更新科室' })
|
||||
update(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
|
||||
@ -48,7 +48,7 @@ export class DepartmentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询科室列表:院管限定本院;主任/组长限定本科室。
|
||||
* 查询科室列表:院管限定本院。
|
||||
*/
|
||||
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
||||
this.access.assertRole(actor, [
|
||||
@ -56,6 +56,7 @@ export class DepartmentsService {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
]);
|
||||
const paging = this.access.resolvePaging(query);
|
||||
const where: Prisma.DepartmentWhereInput = {};
|
||||
@ -66,7 +67,11 @@ export class DepartmentsService {
|
||||
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
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);
|
||||
} else if (query.hospitalId != null) {
|
||||
where.hospitalId = this.access.toInt(
|
||||
@ -93,7 +98,7 @@ export class DepartmentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询科室详情:院管仅可查看本院;主任/组长仅可查看本科室。
|
||||
* 查询科室详情:院管仅可查看本院。
|
||||
*/
|
||||
async findOne(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [
|
||||
@ -101,6 +106,7 @@ export class DepartmentsService {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
]);
|
||||
const departmentId = this.access.toInt(
|
||||
id,
|
||||
@ -118,18 +124,21 @@ export class DepartmentsService {
|
||||
}
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
this.access.assertHospitalScope(actor, department.hospitalId);
|
||||
}
|
||||
if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
|
||||
} else if (
|
||||
actor.role === Role.DIRECTOR ||
|
||||
actor.role === Role.LEADER ||
|
||||
actor.role === Role.DOCTOR
|
||||
) {
|
||||
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
||||
if (department.id !== actorDepartmentId) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
|
||||
}
|
||||
}
|
||||
return department;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新科室:院管仅可修改本院;主任/组长仅可修改本科室。
|
||||
* 更新科室:院管仅可修改本院。
|
||||
*/
|
||||
async update(actor: ActorContext, id: number, dto: UpdateDepartmentDto) {
|
||||
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 type { ActorContext } from '../common/actor-context.js';
|
||||
import { MESSAGES } from '../common/messages.js';
|
||||
import {
|
||||
normalizePressureLabelList,
|
||||
normalizePressureLabel,
|
||||
} from '../common/pressure-level.util.js';
|
||||
import { PrismaService } from '../prisma.service.js';
|
||||
import { CreateImplantCatalogDto } from './dto/create-implant-catalog.dto.js';
|
||||
import { CreateDeviceDto } from './dto/create-device.dto.js';
|
||||
@ -133,15 +137,12 @@ export class DevicesService {
|
||||
async create(actor: ActorContext, dto: CreateDeviceDto) {
|
||||
this.assertAdmin(actor);
|
||||
|
||||
const snCode = this.normalizeSnCode(dto.snCode);
|
||||
const patient = await this.resolveWritablePatient(actor, dto.patientId);
|
||||
await this.assertSnCodeUnique(snCode);
|
||||
|
||||
return this.prisma.device.create({
|
||||
data: {
|
||||
snCode,
|
||||
// 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。
|
||||
currentPressure: 0,
|
||||
currentPressure: '0',
|
||||
status: dto.status ?? DeviceStatus.ACTIVE,
|
||||
patientId: patient.id,
|
||||
},
|
||||
@ -150,17 +151,12 @@ export class DevicesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设备:允许修改 SN、状态和归属患者;当前压力仅由任务完成时更新。
|
||||
* 更新设备:允许修改状态和归属患者;当前压力仅由任务完成时更新。
|
||||
*/
|
||||
async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) {
|
||||
const current = await this.findOne(actor, id);
|
||||
|
||||
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) {
|
||||
data.status = this.normalizeStatus(dto.status);
|
||||
}
|
||||
@ -366,12 +362,6 @@ export class DevicesService {
|
||||
if (keyword) {
|
||||
andConditions.push({
|
||||
OR: [
|
||||
{
|
||||
snCode: {
|
||||
contains: keyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
implantModel: {
|
||||
contains: keyword,
|
||||
@ -590,13 +580,6 @@ export class DevicesService {
|
||||
return this.normalizeRequiredString(value, 'modelCode').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备 SN 标准化:统一去空白并转大写,避免大小写重复。
|
||||
*/
|
||||
private normalizeSnCode(value: unknown) {
|
||||
return this.normalizeRequiredString(value, 'snCode').toUpperCase();
|
||||
}
|
||||
|
||||
private normalizeRequiredString(value: unknown, fieldName: string) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(`${fieldName} 必须是字符串`);
|
||||
@ -622,41 +605,25 @@ export class DevicesService {
|
||||
* 挡位列表标准化:去重、排序,并在非可调压目录下自动清空。
|
||||
*/
|
||||
private normalizePressureLevels(
|
||||
pressureLevels: number[] | undefined,
|
||||
pressureLevels: unknown[] | undefined,
|
||||
isPressureAdjustable: boolean,
|
||||
) {
|
||||
if (!isPressureAdjustable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(pressureLevels) || pressureLevels.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
pressureLevels.map((level) => {
|
||||
const normalized = Number(level);
|
||||
if (!Number.isInteger(normalized) || normalized < 0) {
|
||||
throw new BadRequestException(
|
||||
'pressureLevels 必须为大于等于 0 的整数数组',
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}),
|
||||
),
|
||||
).sort((left, right) => left - right);
|
||||
return normalizePressureLabelList(pressureLevels, 'pressureLevels');
|
||||
}
|
||||
|
||||
/**
|
||||
* 压力值必须是非负整数。
|
||||
* 当前压力挡位标签标准化。
|
||||
*/
|
||||
private normalizePressure(value: unknown) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
try {
|
||||
return normalizePressureLabel(value, 'currentPressure');
|
||||
} catch {
|
||||
throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -693,18 +660,4 @@ export class DevicesService {
|
||||
}
|
||||
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 { DeviceStatus } from '../../generated/prisma/enums.js';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator';
|
||||
import { IsEnum, IsInt, IsOptional, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 创建设备 DTO。
|
||||
*/
|
||||
export class CreateDeviceDto {
|
||||
@ApiProperty({ description: '设备 SN', example: 'TYT-SN-10001' })
|
||||
@IsString({ message: 'snCode 必须是字符串' })
|
||||
snCode!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '设备状态,默认 ACTIVE',
|
||||
enum: DeviceStatus,
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
|
||||
|
||||
@ -38,17 +35,15 @@ export class CreateImplantCatalogDto {
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '可调压器械的挡位列表,按整数录入',
|
||||
type: [Number],
|
||||
example: [80, 100, 120, 140],
|
||||
description: '可调压器械的挡位列表,按字符串挡位标签录入',
|
||||
type: [String],
|
||||
example: ['0.5', '1', '1.5'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'pressureLevels 必须是数组' })
|
||||
@ArrayMaxSize(30, { message: 'pressureLevels 最多 30 项' })
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true, message: 'pressureLevels 必须为整数数组' })
|
||||
@Min(0, { each: true, message: 'pressureLevels 必须大于等于 0' })
|
||||
pressureLevels?: number[];
|
||||
@IsString({ each: true, message: 'pressureLevels 必须为字符串数组' })
|
||||
pressureLevels?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '是否支持调压,默认 true',
|
||||
|
||||
@ -9,8 +9,9 @@ import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
*/
|
||||
export class DeviceQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '关键词(支持设备 SN / 患者姓名 / 患者手机号)',
|
||||
example: 'SN-A',
|
||||
description:
|
||||
'关键词(支持植入物型号 / 植入物名称 / 患者姓名 / 患者手机号)',
|
||||
example: '脑室',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'keyword 必须是字符串' })
|
||||
|
||||
@ -41,7 +41,7 @@ export class GroupsController {
|
||||
* 创建小组。
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '创建小组' })
|
||||
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateGroupDto) {
|
||||
return this.groupsService.create(actor, dto);
|
||||
@ -51,7 +51,13 @@ export class GroupsController {
|
||||
* 查询小组列表。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '查询小组列表' })
|
||||
findAll(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@ -64,7 +70,13 @@ export class GroupsController {
|
||||
* 查询小组详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '查询小组详情' })
|
||||
@ApiParam({ name: 'id', description: '小组 ID' })
|
||||
findOne(
|
||||
@ -78,7 +90,7 @@ export class GroupsController {
|
||||
* 更新小组。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '更新小组' })
|
||||
update(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@ -92,7 +104,7 @@ export class GroupsController {
|
||||
* 删除小组。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '删除小组' })
|
||||
remove(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
|
||||
@ -26,14 +26,10 @@ export class GroupsService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建小组:系统管理员可跨院;院管仅可在本院;主任仅可在本科室创建。
|
||||
* 创建小组:系统管理员可跨院;院管仅可在本院。
|
||||
*/
|
||||
async create(actor: ActorContext, dto: CreateGroupDto) {
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
]);
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
const departmentId = this.access.toInt(
|
||||
dto.departmentId,
|
||||
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
|
||||
@ -42,12 +38,6 @@ export class GroupsService {
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
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({
|
||||
data: {
|
||||
@ -61,7 +51,7 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询小组列表:院管限定本院;主任限定本科室;组长限定本组。
|
||||
* 查询小组列表:院管限定本院。
|
||||
*/
|
||||
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
||||
this.access.assertRole(actor, [
|
||||
@ -69,6 +59,7 @@ export class GroupsService {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
]);
|
||||
const paging = this.access.resolvePaging(query);
|
||||
const where: Prisma.GroupWhereInput = {};
|
||||
@ -87,10 +78,12 @@ export class GroupsService {
|
||||
where.department = {
|
||||
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);
|
||||
} else if (actor.role === Role.LEADER) {
|
||||
where.id = this.access.requireActorGroupId(actor);
|
||||
} else if (query.hospitalId != null) {
|
||||
where.department = {
|
||||
hospitalId: this.access.toInt(
|
||||
@ -118,7 +111,7 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询小组详情:院管仅可查看本院;主任仅可查看本科室;组长仅可查看本组。
|
||||
* 查询小组详情:院管仅可查看本院。
|
||||
*/
|
||||
async findOne(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [
|
||||
@ -126,6 +119,7 @@ export class GroupsService {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
]);
|
||||
const groupId = this.access.toInt(id, MESSAGES.ORG.GROUP_ID_REQUIRED);
|
||||
const group = await this.prisma.group.findUnique({
|
||||
@ -140,24 +134,21 @@ export class GroupsService {
|
||||
}
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
this.access.assertHospitalScope(actor, group.department.hospital.id);
|
||||
}
|
||||
if (actor.role === Role.DIRECTOR) {
|
||||
} else if (
|
||||
actor.role === Role.DIRECTOR ||
|
||||
actor.role === Role.LEADER ||
|
||||
actor.role === Role.DOCTOR
|
||||
) {
|
||||
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
||||
if (group.department.id !== actorDepartmentId) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
if (actor.role === Role.LEADER) {
|
||||
const actorGroupId = this.access.requireActorGroupId(actor);
|
||||
if (group.id !== actorGroupId) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
|
||||
}
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新小组:院管仅可修改本院;主任仅可修改本科室;组长仅可修改本组。
|
||||
* 更新小组:院管仅可修改本院。
|
||||
*/
|
||||
async update(actor: ActorContext, id: number, dto: UpdateGroupDto) {
|
||||
const current = await this.findOne(actor, id);
|
||||
@ -181,14 +172,10 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除小组:院管仅可删除本院;主任仅可删除本科室小组。
|
||||
* 删除小组:院管仅可删除本院。
|
||||
*/
|
||||
async remove(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
]);
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
const current = await this.findOne(actor, id);
|
||||
|
||||
// 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
import 'dotenv/config';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { HttpExceptionFilter } from './common/http-exception.filter.js';
|
||||
import { MESSAGES } from './common/messages.js';
|
||||
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
|
||||
import { resolveUploadRootDir } from './uploads/upload-path.util.js';
|
||||
|
||||
async function bootstrap() {
|
||||
// 创建应用实例并加载核心模块。
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.enableCors();
|
||||
mkdirSync(resolveUploadRootDir(), { recursive: true });
|
||||
app.useStaticAssets(resolveUploadRootDir(), {
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
@ -11,6 +10,7 @@ import { DeviceStatus, Role } from '../../generated/prisma/enums.js';
|
||||
import { PrismaService } from '../../prisma.service.js';
|
||||
import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { MESSAGES } from '../../common/messages.js';
|
||||
import { normalizePressureLabel } from '../../common/pressure-level.util.js';
|
||||
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
||||
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
|
||||
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
||||
@ -33,10 +33,10 @@ const IMPLANT_CATALOG_SELECT = {
|
||||
const PATIENT_LIST_INCLUDE = {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: { select: { id: true, name: true, role: true } },
|
||||
creator: { select: { id: true, name: true, role: true } },
|
||||
devices: {
|
||||
select: {
|
||||
id: true,
|
||||
snCode: true,
|
||||
status: true,
|
||||
currentPressure: true,
|
||||
isAbandoned: true,
|
||||
@ -52,6 +52,7 @@ const PATIENT_LIST_INCLUDE = {
|
||||
id: true,
|
||||
surgeryDate: true,
|
||||
surgeryName: true,
|
||||
surgeonId: true,
|
||||
surgeonName: true,
|
||||
},
|
||||
orderBy: { surgeryDate: 'desc' },
|
||||
@ -76,6 +77,13 @@ const PATIENT_DETAIL_INCLUDE = {
|
||||
groupId: true,
|
||||
},
|
||||
},
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
include: {
|
||||
implantCatalog: {
|
||||
@ -101,6 +109,13 @@ const PATIENT_DETAIL_INCLUDE = {
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
},
|
||||
surgeon: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { surgeryDate: 'desc' },
|
||||
},
|
||||
@ -212,6 +227,7 @@ export class BPatientsService {
|
||||
const patient = await tx.patient.create({
|
||||
data: {
|
||||
name: this.normalizeRequiredString(dto.name, 'name'),
|
||||
creatorId: actor.id,
|
||||
inpatientNo:
|
||||
dto.inpatientNo === undefined
|
||||
? undefined
|
||||
@ -231,7 +247,9 @@ export class BPatientsService {
|
||||
if (dto.initialSurgery) {
|
||||
await this.createPatientSurgeryRecord(
|
||||
tx,
|
||||
actor,
|
||||
patient.id,
|
||||
patient.doctorId,
|
||||
dto.initialSurgery,
|
||||
);
|
||||
}
|
||||
@ -255,7 +273,9 @@ export class BPatientsService {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const createdSurgery = await this.createPatientSurgeryRecord(
|
||||
tx,
|
||||
actor,
|
||||
patient.id,
|
||||
patient.doctorId,
|
||||
dto,
|
||||
);
|
||||
|
||||
@ -440,6 +460,7 @@ export class BPatientsService {
|
||||
where: { id: normalizedDoctorId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
role: true,
|
||||
hospitalId: true,
|
||||
departmentId: true,
|
||||
@ -553,7 +574,9 @@ export class BPatientsService {
|
||||
*/
|
||||
private async createPatientSurgeryRecord(
|
||||
prisma: PrismaExecutor,
|
||||
actor: ActorContext,
|
||||
patientId: number,
|
||||
patientDoctorId: number,
|
||||
dto: CreatePatientSurgeryDto,
|
||||
) {
|
||||
if (!Array.isArray(dto.devices) || dto.devices.length === 0) {
|
||||
@ -571,13 +594,14 @@ export class BPatientsService {
|
||||
new Set(dto.abandonedDeviceIds ?? []),
|
||||
);
|
||||
|
||||
const [catalogMap, latestSurgery] = await Promise.all([
|
||||
const [catalogMap, latestSurgery, surgeon] = await Promise.all([
|
||||
this.resolveImplantCatalogMap(prisma, catalogIds),
|
||||
prisma.patientSurgery.findFirst({
|
||||
where: { patientId },
|
||||
orderBy: { surgeryDate: 'desc' },
|
||||
select: { surgeryDate: true },
|
||||
}),
|
||||
this.resolveWritableDoctor(actor, patientDoctorId),
|
||||
]);
|
||||
|
||||
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);
|
||||
if (!catalog) {
|
||||
throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND);
|
||||
@ -607,7 +631,7 @@ export class BPatientsService {
|
||||
? null
|
||||
: this.assertPressureLevelAllowed(
|
||||
catalog,
|
||||
this.normalizeNonNegativeInteger(
|
||||
this.normalizePressureLevel(
|
||||
device.initialPressure,
|
||||
'initialPressure',
|
||||
),
|
||||
@ -615,18 +639,17 @@ export class BPatientsService {
|
||||
const fallbackPressureLevel =
|
||||
catalog.isPressureAdjustable && catalog.pressureLevels.length > 0
|
||||
? catalog.pressureLevels[0]
|
||||
: 0;
|
||||
: '0';
|
||||
const currentPressure = catalog.isPressureAdjustable
|
||||
? this.assertPressureLevelAllowed(
|
||||
catalog,
|
||||
initialPressure ?? fallbackPressureLevel,
|
||||
)
|
||||
: 0;
|
||||
: '0';
|
||||
|
||||
return {
|
||||
patient: { connect: { id: patientId } },
|
||||
implantCatalog: { connect: { id: catalog.id } },
|
||||
snCode: this.resolveDeviceSnCode(device.snCode, patientId, index),
|
||||
currentPressure,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
implantModel: catalog.modelCode,
|
||||
@ -662,11 +685,6 @@ export class BPatientsService {
|
||||
};
|
||||
});
|
||||
|
||||
await this.assertSnCodesUnique(
|
||||
prisma,
|
||||
deviceDrafts.map((device) => device.snCode),
|
||||
);
|
||||
|
||||
const surgery = await prisma.patientSurgery.create({
|
||||
data: {
|
||||
patientId,
|
||||
@ -675,10 +693,8 @@ export class BPatientsService {
|
||||
dto.surgeryName,
|
||||
'surgeryName',
|
||||
),
|
||||
surgeonName: this.normalizeRequiredString(
|
||||
dto.surgeonName,
|
||||
'surgeonName',
|
||||
),
|
||||
surgeonId: surgeon.id,
|
||||
surgeonName: surgeon.name,
|
||||
preOpPressure:
|
||||
dto.preOpPressure == null
|
||||
? null
|
||||
@ -765,9 +781,9 @@ export class BPatientsService {
|
||||
private assertPressureLevelAllowed(
|
||||
catalog: {
|
||||
isPressureAdjustable: boolean;
|
||||
pressureLevels: number[];
|
||||
pressureLevels: string[];
|
||||
},
|
||||
pressure: number,
|
||||
pressure: string,
|
||||
) {
|
||||
if (
|
||||
catalog.isPressureAdjustable &&
|
||||
@ -898,6 +914,10 @@ export class BPatientsService {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private normalizePressureLevel(value: unknown, fieldName: string) {
|
||||
return normalizePressureLabel(value, fieldName);
|
||||
}
|
||||
|
||||
private normalizeStringArray(value: unknown, fieldName: string) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new BadRequestException(`${fieldName} 必须为非空数组`);
|
||||
@ -927,39 +947,6 @@ export class BPatientsService {
|
||||
})) as Prisma.InputJsonArray;
|
||||
}
|
||||
|
||||
private resolveDeviceSnCode(
|
||||
snCode: string | undefined,
|
||||
patientId: number,
|
||||
index: number,
|
||||
) {
|
||||
if (snCode) {
|
||||
return this.normalizeRequiredString(snCode, 'snCode').toUpperCase();
|
||||
}
|
||||
|
||||
return `SURG-${patientId}-${Date.now()}-${index + 1}-${randomUUID()
|
||||
.slice(0, 8)
|
||||
.toUpperCase()}`;
|
||||
}
|
||||
|
||||
private async assertSnCodesUnique(prisma: PrismaExecutor, snCodes: string[]) {
|
||||
const uniqueSnCodes = Array.from(new Set(snCodes));
|
||||
if (uniqueSnCodes.length !== snCodes.length) {
|
||||
throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE);
|
||||
}
|
||||
|
||||
const existing = await prisma.device.findMany({
|
||||
where: {
|
||||
snCode: { in: uniqueSnCodes },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE);
|
||||
}
|
||||
}
|
||||
|
||||
private toInt(value: unknown, fieldName: string) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
|
||||
@ -110,11 +110,10 @@ export class CPatientsService {
|
||||
},
|
||||
devices: surgery.devices.map((device) => ({
|
||||
id: this.toJsonNumber(device.id),
|
||||
snCode: device.snCode,
|
||||
status: device.status,
|
||||
isAbandoned: device.isAbandoned,
|
||||
currentPressure: this.toJsonNumber(device.currentPressure),
|
||||
initialPressure: this.toJsonNumber(device.initialPressure),
|
||||
currentPressure: device.currentPressure,
|
||||
initialPressure: device.initialPressure,
|
||||
implantModel: device.implantModel,
|
||||
implantManufacturer: device.implantManufacturer,
|
||||
implantName: device.implantName,
|
||||
@ -149,10 +148,9 @@ export class CPatientsService {
|
||||
},
|
||||
device: {
|
||||
id: this.toJsonNumber(device.id),
|
||||
snCode: device.snCode,
|
||||
status: device.status,
|
||||
isAbandoned: device.isAbandoned,
|
||||
currentPressure: this.toJsonNumber(device.currentPressure),
|
||||
currentPressure: device.currentPressure,
|
||||
implantModel: device.implantModel,
|
||||
implantManufacturer: device.implantManufacturer,
|
||||
implantName: device.implantName,
|
||||
@ -172,8 +170,8 @@ export class CPatientsService {
|
||||
},
|
||||
taskItem: {
|
||||
id: this.toJsonNumber(taskItem.id),
|
||||
oldPressure: this.toJsonNumber(taskItem.oldPressure),
|
||||
targetPressure: this.toJsonNumber(taskItem.targetPressure),
|
||||
oldPressure: taskItem.oldPressure,
|
||||
targetPressure: taskItem.targetPressure,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@ -32,13 +32,6 @@ export class CreatePatientSurgeryDto {
|
||||
@IsString({ message: 'surgeryName 必须是字符串' })
|
||||
surgeryName!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '主刀医生',
|
||||
example: '张主任',
|
||||
})
|
||||
@IsString({ message: 'surgeonName 必须是字符串' })
|
||||
surgeonName!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '术前测压,可为空',
|
||||
example: 22,
|
||||
|
||||
@ -23,14 +23,6 @@ export class CreateSurgeryDeviceDto {
|
||||
@Min(1, { message: 'implantCatalogId 必须大于 0' })
|
||||
implantCatalogId!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '设备 SN,可不传;不传时系统自动生成',
|
||||
example: 'TYT-SHUNT-001',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'snCode 必须是字符串' })
|
||||
snCode?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '分流方式',
|
||||
example: 'VPS',
|
||||
@ -68,14 +60,12 @@ export class CreateSurgeryDeviceDto {
|
||||
distalShuntDirection!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '初始压力,可为空',
|
||||
example: 120,
|
||||
description: '初始压力挡位,可为空',
|
||||
example: '1.5',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'initialPressure 必须是整数' })
|
||||
@Min(0, { message: 'initialPressure 必须大于等于 0' })
|
||||
initialPressure?: number;
|
||||
@IsString({ message: 'initialPressure 必须是字符串' })
|
||||
initialPressure?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '植入物备注',
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||
@ -11,6 +16,8 @@ import { PublishTaskDto } from '../dto/publish-task.dto.js';
|
||||
import { AcceptTaskDto } from '../dto/accept-task.dto.js';
|
||||
import { CompleteTaskDto } from '../dto/complete-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 端任务控制器:封装调压任务状态流转接口。
|
||||
@ -23,21 +30,78 @@ export class BTasksController {
|
||||
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')
|
||||
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER)
|
||||
@ApiOperation({ summary: '发布任务(DOCTOR/DIRECTOR/LEADER)' })
|
||||
@Roles(
|
||||
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) {
|
||||
return this.taskService.publishTask(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工程师接收调压任务。
|
||||
* 工程师接收调压任务(当前流程已停用)。
|
||||
*/
|
||||
@Post('accept')
|
||||
@Roles(Role.ENGINEER)
|
||||
@ApiOperation({ summary: '接收任务(ENGINEER)' })
|
||||
@ApiOperation({ summary: '接收任务(已停用)' })
|
||||
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
|
||||
return this.taskService.acceptTask(actor, dto);
|
||||
}
|
||||
@ -53,11 +117,19 @@ export class BTasksController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 医生/主任/组长取消调压任务(仅任务创建者)。
|
||||
* 系统管理员/医院管理员/医生/主任/组长取消调压任务(仅任务创建者)。
|
||||
*/
|
||||
@Post('cancel')
|
||||
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER)
|
||||
@ApiOperation({ summary: '取消任务(DOCTOR/DIRECTOR/LEADER)' })
|
||||
@Roles(
|
||||
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) {
|
||||
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 { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@ -20,23 +19,20 @@ export class PublishTaskItemDto {
|
||||
@Min(1, { message: 'deviceId 必须大于 0' })
|
||||
deviceId!: number;
|
||||
|
||||
@ApiProperty({ description: '目标压力值', example: 120 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'targetPressure 必须是整数' })
|
||||
targetPressure!: number;
|
||||
@ApiProperty({ description: '目标挡位标签', example: '1.5' })
|
||||
@IsString({ message: 'targetPressure 必须是字符串' })
|
||||
targetPressure!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布任务 DTO。
|
||||
*/
|
||||
export class PublishTaskDto {
|
||||
@ApiPropertyOptional({ description: '指定工程师 ID(可选)', example: 2 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@ApiProperty({ description: '接收工程师 ID', example: 2 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'engineerId 必须是整数' })
|
||||
@Min(1, { message: 'engineerId 必须大于 0' })
|
||||
engineerId?: number;
|
||||
engineerId!: number;
|
||||
|
||||
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
|
||||
@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,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Prisma } from '../generated/prisma/client.js';
|
||||
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
|
||||
import { PrismaService } from '../prisma.service.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 { CompleteTaskDto } from './dto/complete-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 { 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) {
|
||||
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
this.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DOCTOR,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
]);
|
||||
|
||||
if (!Array.isArray(dto.items) || dto.items.length === 0) {
|
||||
throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED);
|
||||
@ -42,27 +196,32 @@ export class TaskService {
|
||||
if (!Number.isInteger(item.deviceId)) {
|
||||
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
|
||||
}
|
||||
if (!Number.isInteger(item.targetPressure)) {
|
||||
throw new BadRequestException(
|
||||
`targetPressure 非法: ${item.targetPressure}`,
|
||||
);
|
||||
}
|
||||
return item.deviceId;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const scopedHospitalId = this.resolveScopedHospitalId(actor);
|
||||
const devices = await this.prisma.device.findMany({
|
||||
where: {
|
||||
id: { in: deviceIds },
|
||||
status: DeviceStatus.ACTIVE,
|
||||
isAbandoned: false,
|
||||
isPressureAdjustable: true,
|
||||
patient: { hospitalId },
|
||||
patient: scopedHospitalId
|
||||
? {
|
||||
hospitalId: scopedHospitalId,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
currentPressure: true,
|
||||
patient: {
|
||||
select: {
|
||||
hospitalId: true,
|
||||
},
|
||||
},
|
||||
implantCatalog: {
|
||||
select: {
|
||||
pressureLevels: true,
|
||||
@ -75,18 +234,21 @@ export class TaskService {
|
||||
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (dto.engineerId != null) {
|
||||
const engineer = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: dto.engineerId,
|
||||
role: Role.ENGINEER,
|
||||
hospitalId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!engineer) {
|
||||
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
|
||||
}
|
||||
const hospitalId = this.resolveTaskHospitalId(
|
||||
actor,
|
||||
devices.map((device) => device.patient.hospitalId),
|
||||
);
|
||||
|
||||
const engineer = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: dto.engineerId,
|
||||
role: Role.ENGINEER,
|
||||
hospitalId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!engineer) {
|
||||
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
|
||||
}
|
||||
|
||||
const pressureByDeviceId = new Map(
|
||||
@ -102,25 +264,30 @@ export class TaskService {
|
||||
);
|
||||
|
||||
dto.items.forEach((item) => {
|
||||
const normalizedTargetPressure = this.normalizeTargetPressure(
|
||||
item.targetPressure,
|
||||
);
|
||||
const pressureLevels = pressureLevelsByDeviceId.get(item.deviceId) ?? [];
|
||||
if (
|
||||
pressureLevels.length > 0 &&
|
||||
!pressureLevels.includes(item.targetPressure)
|
||||
!pressureLevels.includes(normalizedTargetPressure)
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID);
|
||||
}
|
||||
|
||||
item.targetPressure = normalizedTargetPressure;
|
||||
});
|
||||
|
||||
const task = await this.prisma.task.create({
|
||||
data: {
|
||||
status: TaskStatus.PENDING,
|
||||
status: TaskStatus.ACCEPTED,
|
||||
creatorId: actor.id,
|
||||
engineerId: dto.engineerId ?? null,
|
||||
engineerId: engineer.id,
|
||||
hospitalId,
|
||||
items: {
|
||||
create: dto.items.map((item) => ({
|
||||
deviceId: item.deviceId,
|
||||
oldPressure: pressureByDeviceId.get(item.deviceId) ?? 0,
|
||||
oldPressure: pressureByDeviceId.get(item.deviceId) ?? '0',
|
||||
targetPressure: item.targetPressure,
|
||||
})),
|
||||
},
|
||||
@ -139,68 +306,10 @@ export class TaskService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收任务:工程师将任务从 PENDING 流转到 ACCEPTED。
|
||||
* 接收任务:当前流程已停用,统一改为发布时直接指定接收工程师。
|
||||
*/
|
||||
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
|
||||
this.assertRole(actor, [Role.ENGINEER]);
|
||||
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;
|
||||
async acceptTask(_actor: ActorContext, _dto: AcceptTaskDto) {
|
||||
throw new ForbiddenException(MESSAGES.TASK.ACCEPT_DISABLED);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -263,13 +372,19 @@ export class TaskService {
|
||||
* 取消任务:任务创建者可将 PENDING/ACCEPTED 任务取消。
|
||||
*/
|
||||
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
||||
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
this.assertRole(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({
|
||||
where: {
|
||||
id: dto.taskId,
|
||||
hospitalId,
|
||||
hospitalId: scopedHospitalId ?? undefined,
|
||||
},
|
||||
select: {
|
||||
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 {
|
||||
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()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '创建用户' })
|
||||
create(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@ -61,7 +61,7 @@ export class UsersController {
|
||||
* 查询用户详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||
@ApiOperation({ summary: '查询用户详情' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
|
||||
@ -72,7 +72,7 @@ export class UsersController {
|
||||
* 更新用户。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '更新用户' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
update(
|
||||
@ -87,7 +87,7 @@ export class UsersController {
|
||||
* 删除用户。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.DIRECTOR)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '删除用户' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
|
||||
|
||||
@ -30,6 +30,9 @@ const SAFE_USER_SELECT = {
|
||||
groupId: true,
|
||||
} as const;
|
||||
|
||||
const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const;
|
||||
const LEADER_VISIBLE_ROLES = [Role.DOCTOR] as const;
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@ -202,15 +205,18 @@ export class UsersService {
|
||||
actor.hospitalId,
|
||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
||||
);
|
||||
} else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
|
||||
where.hospitalId = this.requireActorScopeInt(
|
||||
actor.hospitalId,
|
||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
||||
);
|
||||
} else if (actor.role === Role.DIRECTOR) {
|
||||
where.departmentId = this.requireActorScopeInt(
|
||||
actor.departmentId,
|
||||
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) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
@ -682,36 +688,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||
if (actor.role !== Role.DIRECTOR) {
|
||||
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,
|
||||
};
|
||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
||||
}
|
||||
|
||||
if (
|
||||
@ -740,7 +717,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取权限:系统管理员可读全量;院管可读本院;主任可读本科室医生。
|
||||
* 读取权限:系统管理员可读全量;院管可读本院。
|
||||
*/
|
||||
private assertUserReadable(
|
||||
actor: ActorContext,
|
||||
@ -749,6 +726,7 @@ export class UsersService {
|
||||
role: Role;
|
||||
hospitalId: number | null;
|
||||
departmentId: number | null;
|
||||
groupId: number | null;
|
||||
},
|
||||
) {
|
||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||
@ -769,30 +747,36 @@ export class UsersService {
|
||||
}
|
||||
|
||||
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
|
||||
target.departmentId === actorDepartmentId &&
|
||||
(DIRECTOR_VISIBLE_ROLES as readonly Role[]).includes(target.role)
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写权限:院管可写本院非管理员账号;主任仅可写本科室医生。
|
||||
* 写权限:院管可写本院非管理员账号。
|
||||
*/
|
||||
private assertUserWritable(
|
||||
actor: ActorContext,
|
||||
@ -807,25 +791,6 @@ export class UsersService {
|
||||
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) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
@ -857,10 +822,6 @@ export class UsersService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (actor.role === Role.DIRECTOR && nextRole !== Role.DOCTOR) {
|
||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
|
||||
if (
|
||||
actor.role === Role.HOSPITAL_ADMIN &&
|
||||
(nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN)
|
||||
@ -878,17 +839,6 @@ export class UsersService {
|
||||
actor: ActorContext,
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@ -911,17 +861,9 @@ export class UsersService {
|
||||
actor: ActorContext,
|
||||
departmentId: number | null,
|
||||
) {
|
||||
if (actor.role !== Role.DIRECTOR) {
|
||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||
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,
|
||||
E2E_SEED_CREDENTIALS,
|
||||
} from '../fixtures/e2e-roles.js';
|
||||
import type { E2ESeedFixtures } from './e2e-fixtures.helper.js';
|
||||
import { expectSuccessEnvelope } from './e2e-http.helper.js';
|
||||
|
||||
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(
|
||||
app: INestApplication,
|
||||
role: E2ERole,
|
||||
fixtures: E2ESeedFixtures,
|
||||
): Promise<string> {
|
||||
const credential = E2E_SEED_CREDENTIALS[role];
|
||||
const payload: Record<string, unknown> = {
|
||||
@ -19,9 +29,9 @@ export async function loginAsRole(
|
||||
password: credential.password,
|
||||
role: credential.role,
|
||||
};
|
||||
|
||||
if (credential.hospitalId != null) {
|
||||
payload.hospitalId = credential.hospitalId;
|
||||
const hospitalId = resolveRoleHospitalId(role, fixtures);
|
||||
if (hospitalId != null) {
|
||||
payload.hospitalId = hospitalId;
|
||||
}
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@ -36,10 +46,11 @@ export async function loginAsRole(
|
||||
|
||||
export async function loginAllRoles(
|
||||
app: INestApplication,
|
||||
fixtures: E2ESeedFixtures,
|
||||
): Promise<E2EAccessTokenMap> {
|
||||
const tokenEntries = await Promise.all(
|
||||
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 { createE2eApp } from './e2e-app.helper.js';
|
||||
import {
|
||||
loadSeedFixtures,
|
||||
ensureE2EFixtures,
|
||||
type E2ESeedFixtures,
|
||||
} from './e2e-fixtures.helper.js';
|
||||
|
||||
@ -17,8 +17,8 @@ export interface E2EContext {
|
||||
export async function createE2EContext(): Promise<E2EContext> {
|
||||
const app = await createE2eApp();
|
||||
const prisma = app.get(PrismaService);
|
||||
const fixtures = await loadSeedFixtures(prisma);
|
||||
const tokens = await loginAllRoles(app);
|
||||
const fixtures = await ensureE2EFixtures(app, prisma);
|
||||
const tokens = await loginAllRoles(app, fixtures);
|
||||
|
||||
return {
|
||||
app,
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import type { INestApplication } 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 {
|
||||
E2E_SEED_CREDENTIALS,
|
||||
E2E_SEED_PASSWORD,
|
||||
} from '../fixtures/e2e-roles.js';
|
||||
import { expectSuccessEnvelope } from './e2e-http.helper.js';
|
||||
|
||||
export interface E2ESeedFixtures {
|
||||
hospitalAId: number;
|
||||
@ -10,6 +18,10 @@ export interface E2ESeedFixtures {
|
||||
groupA1Id: number;
|
||||
groupA2Id: number;
|
||||
groupB1Id: number;
|
||||
catalogs: {
|
||||
adjustableValveId: number;
|
||||
highPressureValveId: number;
|
||||
};
|
||||
users: {
|
||||
systemAdminId: number;
|
||||
hospitalAdminAId: number;
|
||||
@ -44,6 +56,610 @@ interface SeedUserScope {
|
||||
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(
|
||||
prisma: PrismaService,
|
||||
openId: string,
|
||||
@ -63,16 +679,30 @@ async function requireUserScope(
|
||||
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(
|
||||
prisma: PrismaService,
|
||||
snCode: string,
|
||||
implantNotes: string,
|
||||
): Promise<number> {
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { snCode },
|
||||
const device = await prisma.device.findFirst({
|
||||
where: { implantNotes },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!device) {
|
||||
throw new NotFoundException(`Seed device not found: ${snCode}`);
|
||||
throw new NotFoundException(`Seed device not found: ${implantNotes}`);
|
||||
}
|
||||
return device.id;
|
||||
}
|
||||
@ -96,22 +726,16 @@ async function requirePatientId(
|
||||
export async function loadSeedFixtures(
|
||||
prisma: PrismaService,
|
||||
): Promise<E2ESeedFixtures> {
|
||||
const systemAdmin = await requireUserScope(
|
||||
prisma,
|
||||
'seed-system-admin-openid',
|
||||
);
|
||||
const hospitalAdminA = await requireUserScope(
|
||||
prisma,
|
||||
'seed-hospital-admin-a-openid',
|
||||
);
|
||||
const directorA = await requireUserScope(prisma, 'seed-director-a-openid');
|
||||
const leaderA = await requireUserScope(prisma, 'seed-leader-a-openid');
|
||||
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 systemAdmin = await requireUserScope(prisma, OPEN_IDS.systemAdmin);
|
||||
const hospitalAdminA = await requireUserScope(prisma, OPEN_IDS.hospitalAdminA);
|
||||
const directorA = await requireUserScope(prisma, OPEN_IDS.directorA);
|
||||
const leaderA = await requireUserScope(prisma, OPEN_IDS.leaderA);
|
||||
const doctorA = await requireUserScope(prisma, OPEN_IDS.doctorA);
|
||||
const doctorA2 = await requireUserScope(prisma, OPEN_IDS.doctorA2);
|
||||
const doctorA3 = await requireUserScope(prisma, OPEN_IDS.doctorA3);
|
||||
const doctorB = await requireUserScope(prisma, OPEN_IDS.doctorB);
|
||||
const engineerA = await requireUserScope(prisma, OPEN_IDS.engineerA);
|
||||
const engineerB = await requireUserScope(prisma, OPEN_IDS.engineerB);
|
||||
|
||||
const hospitalAId = hospitalAdminA.hospitalId;
|
||||
const hospitalBId = doctorB.hospitalId;
|
||||
@ -144,6 +768,16 @@ export async function loadSeedFixtures(
|
||||
groupA1Id,
|
||||
groupA2Id,
|
||||
groupB1Id,
|
||||
catalogs: {
|
||||
adjustableValveId: await requireCatalogId(
|
||||
prisma,
|
||||
FIXTURE_NAMES.adjustableCatalog,
|
||||
),
|
||||
highPressureValveId: await requireCatalogId(
|
||||
prisma,
|
||||
FIXTURE_NAMES.highPressureCatalog,
|
||||
),
|
||||
},
|
||||
users: {
|
||||
systemAdminId: systemAdmin.id,
|
||||
hospitalAdminAId: hospitalAdminA.id,
|
||||
@ -160,8 +794,8 @@ export async function loadSeedFixtures(
|
||||
patientA1Id: await requirePatientId(
|
||||
prisma,
|
||||
hospitalAId,
|
||||
'13800002001',
|
||||
'110101199001010011',
|
||||
SHARED_PATIENT_IDENTITY.phone,
|
||||
SHARED_PATIENT_IDENTITY.idCard,
|
||||
),
|
||||
patientA2Id: await requirePatientId(
|
||||
prisma,
|
||||
@ -178,16 +812,16 @@ export async function loadSeedFixtures(
|
||||
patientB1Id: await requirePatientId(
|
||||
prisma,
|
||||
hospitalBId,
|
||||
'13800002001',
|
||||
'110101199001010011',
|
||||
SHARED_PATIENT_IDENTITY.phone,
|
||||
SHARED_PATIENT_IDENTITY.idCard,
|
||||
),
|
||||
},
|
||||
devices: {
|
||||
deviceA1Id: await requireDeviceId(prisma, 'SEED-SN-A-001'),
|
||||
deviceA2Id: await requireDeviceId(prisma, 'SEED-SN-A-002'),
|
||||
deviceA3Id: await requireDeviceId(prisma, 'SEED-SN-A-003'),
|
||||
deviceA4InactiveId: await requireDeviceId(prisma, 'SEED-SN-A-004'),
|
||||
deviceB1Id: await requireDeviceId(prisma, 'SEED-SN-B-001'),
|
||||
deviceA1Id: await requireDeviceId(prisma, 'Seed A1 当前在用设备'),
|
||||
deviceA2Id: await requireDeviceId(prisma, 'Seed A2 当前在用设备'),
|
||||
deviceA3Id: await requireDeviceId(prisma, 'Seed A3 当前在用设备'),
|
||||
deviceA4InactiveId: await requireDeviceId(prisma, 'Seed A1 弃用历史设备'),
|
||||
deviceB1Id: await requireDeviceId(prisma, 'Seed B1 当前在用设备'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -28,7 +28,6 @@ describe('BDevicesController (e2e)', () => {
|
||||
.post('/b/devices')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
snCode: uniqueSeedValue('device-sn'),
|
||||
status: DeviceStatus.ACTIVE,
|
||||
patientId,
|
||||
});
|
||||
@ -36,8 +35,7 @@ describe('BDevicesController (e2e)', () => {
|
||||
expectSuccessEnvelope(response, 201);
|
||||
return response.body.data as {
|
||||
id: number;
|
||||
snCode: string;
|
||||
currentPressure: number;
|
||||
currentPressure: string;
|
||||
status: DeviceStatus;
|
||||
patient: { id: number };
|
||||
};
|
||||
@ -118,24 +116,28 @@ describe('BDevicesController (e2e)', () => {
|
||||
manufacturer: 'Global Vendor',
|
||||
name: '全局可调压阀',
|
||||
isPressureAdjustable: true,
|
||||
pressureLevels: [70, 90, 110],
|
||||
pressureLevels: ['10.0', '20', '30.0'],
|
||||
notes: '测试全局目录',
|
||||
});
|
||||
|
||||
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())
|
||||
.patch(`/b/devices/catalogs/${createResponse.body.data.id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||
.send({
|
||||
name: '全局可调压阀-更新版',
|
||||
pressureLevels: [80, 100, 120],
|
||||
pressureLevels: ['0.5', '1.0', '1.50'],
|
||||
});
|
||||
|
||||
expectSuccessEnvelope(updateResponse, 200);
|
||||
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())
|
||||
.delete(`/b/devices/catalogs/${createResponse.body.data.id}`)
|
||||
@ -166,7 +168,7 @@ describe('BDevicesController (e2e)', () => {
|
||||
manufacturer: 'Role Matrix Vendor',
|
||||
name: '角色矩阵目录',
|
||||
isPressureAdjustable: true,
|
||||
pressureLevels: [50, 80],
|
||||
pressureLevels: ['10', '20'],
|
||||
}),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
@ -188,9 +190,9 @@ describe('BDevicesController (e2e)', () => {
|
||||
);
|
||||
|
||||
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.snCode).toMatch(/^DEVICE-SN-/);
|
||||
expect(created).not.toHaveProperty('snCode');
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 绑定跨院患者返回 403', async () => {
|
||||
@ -198,7 +200,6 @@ describe('BDevicesController (e2e)', () => {
|
||||
.post('/b/devices')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({
|
||||
snCode: uniqueSeedValue('cross-hospital-device'),
|
||||
status: DeviceStatus.ACTIVE,
|
||||
patientId: ctx.fixtures.patients.patientB1Id,
|
||||
});
|
||||
@ -225,7 +226,7 @@ describe('BDevicesController (e2e)', () => {
|
||||
expect(response.body.data.patient.id).toBe(
|
||||
ctx.fixtures.patients.patientA2Id,
|
||||
);
|
||||
expect(response.body.data.currentPressure).toBe(0);
|
||||
expect(response.body.data.currentPressure).toBe('0');
|
||||
});
|
||||
|
||||
it('失败:设备实例接口不允许手工更新 currentPressure', async () => {
|
||||
@ -238,7 +239,7 @@ describe('BDevicesController (e2e)', () => {
|
||||
.patch(`/b/devices/${created.id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||
.send({
|
||||
currentPressure: 99,
|
||||
currentPressure: '1.5',
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 400, 'currentPressure should not exist');
|
||||
|
||||
@ -321,7 +321,7 @@ describe('Organization Controllers (e2e)', () => {
|
||||
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({
|
||||
name: 'GET /b/organization/departments role matrix',
|
||||
tokens: ctx.tokens,
|
||||
@ -330,7 +330,7 @@ describe('Organization Controllers (e2e)', () => {
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.DOCTOR]: 200,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
@ -361,7 +361,7 @@ describe('Organization Controllers (e2e)', () => {
|
||||
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({
|
||||
name: 'GET /b/organization/departments/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
@ -370,7 +370,7 @@ describe('Organization Controllers (e2e)', () => {
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.DOCTOR]: 200,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
@ -413,15 +413,15 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'PATCH /b/organization/departments/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.LEADER]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -516,14 +516,14 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/organization/groups role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 400,
|
||||
[Role.HOSPITAL_ADMIN]: 400,
|
||||
[Role.DIRECTOR]: 400,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
@ -558,7 +558,7 @@ describe('Organization Controllers (e2e)', () => {
|
||||
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({
|
||||
name: 'GET /b/organization/groups role matrix',
|
||||
tokens: ctx.tokens,
|
||||
@ -567,7 +567,7 @@ describe('Organization Controllers (e2e)', () => {
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.DOCTOR]: 200,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
@ -598,7 +598,7 @@ describe('Organization Controllers (e2e)', () => {
|
||||
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({
|
||||
name: 'GET /b/organization/groups/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
@ -607,7 +607,7 @@ describe('Organization Controllers (e2e)', () => {
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.DOCTOR]: 200,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
@ -650,15 +650,15 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'PATCH /b/organization/groups/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.LEADER]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -710,14 +710,14 @@ describe('Organization Controllers (e2e)', () => {
|
||||
expectErrorEnvelope(response, 409, '小组下仍有成员,无法删除');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'DELETE /b/organization/groups/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import request from 'supertest';
|
||||
import {
|
||||
DeviceStatus,
|
||||
Role,
|
||||
TaskStatus,
|
||||
} from '../../../src/generated/prisma/enums.js';
|
||||
import { DeviceStatus, Role } from '../../../src/generated/prisma/enums.js';
|
||||
import {
|
||||
closeE2EContext,
|
||||
createE2EContext,
|
||||
@ -201,12 +197,6 @@ describe('Patients Controllers (e2e)', () => {
|
||||
|
||||
describe('患者手术录入', () => {
|
||||
it('成功:DOCTOR 可创建患者并附带首台手术和植入设备', async () => {
|
||||
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
|
||||
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(adjustableCatalog).toBeTruthy();
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/patients')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
@ -220,18 +210,17 @@ describe('Patients Controllers (e2e)', () => {
|
||||
initialSurgery: {
|
||||
surgeryDate: '2026-03-19T08:00:00.000Z',
|
||||
surgeryName: '脑室腹腔分流术',
|
||||
surgeonName: 'Seed Doctor A',
|
||||
preOpPressure: 20,
|
||||
primaryDisease: '梗阻性脑积水',
|
||||
hydrocephalusTypes: ['交通性'],
|
||||
devices: [
|
||||
{
|
||||
implantCatalogId: adjustableCatalog!.id,
|
||||
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['额角'],
|
||||
valvePlacementSites: ['耳后'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 120,
|
||||
initialPressure: '1',
|
||||
implantNotes: '首术植入',
|
||||
labelImageUrl:
|
||||
'https://seed.example.com/tests/first-surgery.jpg',
|
||||
@ -241,8 +230,18 @@ describe('Patients Controllers (e2e)', () => {
|
||||
});
|
||||
|
||||
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.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[0].implantModel).toBe(
|
||||
'SEED-ADJUSTABLE-VALVE',
|
||||
@ -250,12 +249,6 @@ describe('Patients Controllers (e2e)', () => {
|
||||
});
|
||||
|
||||
it('成功:新增二次手术时可弃用旧设备且保留旧设备调压历史', async () => {
|
||||
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
|
||||
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(adjustableCatalog).toBeTruthy();
|
||||
|
||||
const createPatientResponse = await request(ctx.app.getHttpServer())
|
||||
.post('/b/patients')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
@ -269,18 +262,17 @@ describe('Patients Controllers (e2e)', () => {
|
||||
initialSurgery: {
|
||||
surgeryDate: '2026-03-01T08:00:00.000Z',
|
||||
surgeryName: '首次分流术',
|
||||
surgeonName: 'Seed Doctor A',
|
||||
preOpPressure: 18,
|
||||
primaryDisease: '出血后脑积水',
|
||||
hydrocephalusTypes: ['高压性'],
|
||||
devices: [
|
||||
{
|
||||
implantCatalogId: adjustableCatalog!.id,
|
||||
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['额角'],
|
||||
valvePlacementSites: ['耳后'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 100,
|
||||
initialPressure: '1',
|
||||
implantNotes: '首术设备',
|
||||
labelImageUrl:
|
||||
'https://seed.example.com/tests/initial-device.jpg',
|
||||
@ -296,23 +288,25 @@ describe('Patients Controllers (e2e)', () => {
|
||||
};
|
||||
const oldDeviceId = patient.devices[0].id;
|
||||
|
||||
await ctx.prisma.task.create({
|
||||
data: {
|
||||
status: TaskStatus.COMPLETED,
|
||||
creatorId: ctx.fixtures.users.doctorAId,
|
||||
const publishResponse = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/publish')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
.send({
|
||||
engineerId: ctx.fixtures.users.engineerAId,
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
items: {
|
||||
create: [
|
||||
{
|
||||
deviceId: oldDeviceId,
|
||||
oldPressure: 100,
|
||||
targetPressure: 120,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
items: [
|
||||
{
|
||||
deviceId: oldDeviceId,
|
||||
targetPressure: '1.5',
|
||||
},
|
||||
],
|
||||
});
|
||||
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())
|
||||
.post(`/b/patients/${patient.id}/surgeries`)
|
||||
@ -320,29 +314,28 @@ describe('Patients Controllers (e2e)', () => {
|
||||
.send({
|
||||
surgeryDate: '2026-03-18T08:00:00.000Z',
|
||||
surgeryName: '二次翻修术',
|
||||
surgeonName: 'Seed Doctor A',
|
||||
preOpPressure: 16,
|
||||
primaryDisease: '分流功能障碍',
|
||||
hydrocephalusTypes: ['交通性', '高压性'],
|
||||
abandonedDeviceIds: [oldDeviceId],
|
||||
devices: [
|
||||
{
|
||||
implantCatalogId: adjustableCatalog!.id,
|
||||
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['枕角'],
|
||||
valvePlacementSites: ['耳后'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 120,
|
||||
initialPressure: '1',
|
||||
implantNotes: '二次手术新设备-1',
|
||||
labelImageUrl: 'https://seed.example.com/tests/revision-1.jpg',
|
||||
},
|
||||
{
|
||||
implantCatalogId: adjustableCatalog!.id,
|
||||
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['额角'],
|
||||
valvePlacementSites: ['胸前'],
|
||||
distalShuntDirection: '胸腔',
|
||||
initialPressure: 140,
|
||||
initialPressure: '1.5',
|
||||
implantNotes: '二次手术新设备-2',
|
||||
labelImageUrl: 'https://seed.example.com/tests/revision-2.jpg',
|
||||
},
|
||||
@ -364,12 +357,6 @@ describe('Patients Controllers (e2e)', () => {
|
||||
});
|
||||
|
||||
it('失败:手术录入设备不允许手工传 currentPressure', async () => {
|
||||
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
|
||||
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(adjustableCatalog).toBeTruthy();
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/patients')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
@ -383,18 +370,17 @@ describe('Patients Controllers (e2e)', () => {
|
||||
initialSurgery: {
|
||||
surgeryDate: '2026-03-19T08:00:00.000Z',
|
||||
surgeryName: '脑室腹腔分流术',
|
||||
surgeonName: 'Seed Doctor A',
|
||||
primaryDisease: '梗阻性脑积水',
|
||||
hydrocephalusTypes: ['交通性'],
|
||||
devices: [
|
||||
{
|
||||
implantCatalogId: adjustableCatalog!.id,
|
||||
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||||
shuntMode: 'VPS',
|
||||
proximalPunctureAreas: ['额角'],
|
||||
valvePlacementSites: ['耳后'],
|
||||
distalShuntDirection: '腹腔',
|
||||
initialPressure: 120,
|
||||
currentPressure: 120,
|
||||
initialPressure: '1',
|
||||
currentPressure: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -22,11 +22,17 @@ describe('BTasksController (e2e)', () => {
|
||||
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())
|
||||
.post('/b/tasks/publish')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
.set('Authorization', `Bearer ${actorToken}`)
|
||||
.send({
|
||||
engineerId,
|
||||
items: [
|
||||
{
|
||||
deviceId,
|
||||
@ -36,11 +42,123 @@ describe('BTasksController (e2e)', () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('成功:DOCTOR 可发布任务', async () => {
|
||||
it('成功:DOCTOR 发布任务时必须直接指定接收工程师', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/publish')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
@ -49,16 +167,30 @@ describe('BTasksController (e2e)', () => {
|
||||
items: [
|
||||
{
|
||||
deviceId: ctx.fixtures.devices.deviceA2Id,
|
||||
targetPressure: 120,
|
||||
targetPressure: '1.5',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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())
|
||||
.post('/b/tasks/publish')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
@ -66,7 +198,24 @@ describe('BTasksController (e2e)', () => {
|
||||
items: [
|
||||
{
|
||||
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')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
.send({
|
||||
engineerId: ctx.fixtures.users.engineerAId,
|
||||
items: [
|
||||
{
|
||||
deviceId: ctx.fixtures.devices.deviceB1Id,
|
||||
targetPressure: 120,
|
||||
targetPressure: '1.5',
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -90,13 +240,13 @@ describe('BTasksController (e2e)', () => {
|
||||
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
|
||||
});
|
||||
|
||||
it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/tasks/publish role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 403,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.SYSTEM_ADMIN]: 400,
|
||||
[Role.HOSPITAL_ADMIN]: 400,
|
||||
[Role.DIRECTOR]: 400,
|
||||
[Role.LEADER]: 400,
|
||||
[Role.DOCTOR]: 400,
|
||||
@ -114,10 +264,10 @@ describe('BTasksController (e2e)', () => {
|
||||
});
|
||||
|
||||
describe('POST /b/tasks/accept', () => {
|
||||
it('成功:ENGINEER 可接收待处理任务', async () => {
|
||||
const task = await publishPendingTask(
|
||||
it('失败:ENGINEER 接收接口已停用,返回 403', async () => {
|
||||
const task = await publishAssignedTask(
|
||||
ctx.fixtures.devices.deviceA2Id,
|
||||
140,
|
||||
'1.5',
|
||||
);
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
@ -125,43 +275,14 @@ describe('BTasksController (e2e)', () => {
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: task.id });
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
expect(response.body.data.status).toBe(TaskStatus.ACCEPTED);
|
||||
expect(response.body.data.engineerId).toBe(
|
||||
ctx.fixtures.users.engineerAId,
|
||||
expectErrorEnvelope(
|
||||
response,
|
||||
403,
|
||||
'当前流程不支持工程师接收,请由创建人直接指定接收工程师',
|
||||
);
|
||||
});
|
||||
|
||||
it('失败:接收不存在任务返回 404', 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 () => {
|
||||
it('角色矩阵:接收接口对所有角色都不可用,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/tasks/accept role matrix',
|
||||
tokens: ctx.tokens,
|
||||
@ -171,7 +292,7 @@ describe('BTasksController (e2e)', () => {
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 404,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
@ -187,19 +308,13 @@ describe('BTasksController (e2e)', () => {
|
||||
});
|
||||
|
||||
describe('POST /b/tasks/complete', () => {
|
||||
it('成功:ENGINEER 完成已接收任务并同步设备压力', async () => {
|
||||
const targetPressure = 140;
|
||||
const task = await publishPendingTask(
|
||||
it('成功:ENGINEER 可直接完成已指派任务并同步设备压力', async () => {
|
||||
const targetPressure = '1.5';
|
||||
const task = await publishAssignedTask(
|
||||
ctx.fixtures.devices.deviceA1Id,
|
||||
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())
|
||||
.post('/b/tasks/complete')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
@ -224,18 +339,24 @@ describe('BTasksController (e2e)', () => {
|
||||
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
||||
});
|
||||
|
||||
it('状态机失败:未接收任务直接完成返回 409', async () => {
|
||||
const task = await publishPendingTask(
|
||||
it('状态机失败:重复完成返回 409', async () => {
|
||||
const task = await publishAssignedTask(
|
||||
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')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: task.id });
|
||||
|
||||
expectErrorEnvelope(response, 409, '仅已接收任务可执行完成');
|
||||
expectErrorEnvelope(secondComplete, 409, '仅已指派任务可执行完成');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
@ -264,10 +385,10 @@ describe('BTasksController (e2e)', () => {
|
||||
});
|
||||
|
||||
describe('POST /b/tasks/cancel', () => {
|
||||
it('成功:DOCTOR 可取消自己创建的任务', async () => {
|
||||
const task = await publishPendingTask(
|
||||
it('成功:DOCTOR 可取消自己创建的已指派任务', async () => {
|
||||
const task = await publishAssignedTask(
|
||||
ctx.fixtures.devices.deviceA3Id,
|
||||
120,
|
||||
'1.5',
|
||||
);
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
@ -289,17 +410,11 @@ describe('BTasksController (e2e)', () => {
|
||||
});
|
||||
|
||||
it('状态机失败:已完成任务不可取消返回 409', async () => {
|
||||
const task = await publishPendingTask(
|
||||
const task = await publishAssignedTask(
|
||||
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())
|
||||
.post('/b/tasks/complete')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
@ -311,16 +426,16 @@ describe('BTasksController (e2e)', () => {
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
.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({
|
||||
name: 'POST /b/tasks/cancel role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 403,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.LEADER]: 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 };
|
||||
}
|
||||
|
||||
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) {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/users')
|
||||
@ -65,8 +84,12 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
|
||||
});
|
||||
|
||||
it('成功:DIRECTOR 可创建本科室医生', async () => {
|
||||
await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
|
||||
it('成功:HOSPITAL_ADMIN 可创建本院医生', async () => {
|
||||
await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
|
||||
});
|
||||
|
||||
it('成功:HOSPITAL_ADMIN 可创建本院组长', async () => {
|
||||
await createLeaderUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
|
||||
});
|
||||
|
||||
it('失败:参数校验失败返回 400', async () => {
|
||||
@ -86,31 +109,31 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
expectErrorEnvelope(response, 400, 'phone 必须是合法手机号');
|
||||
});
|
||||
|
||||
it('失败:DIRECTOR 创建非医生角色返回 403', async () => {
|
||||
it('失败:DIRECTOR 创建用户返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/users')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
||||
.send({
|
||||
name: uniqueSeedValue('主任创建组长'),
|
||||
name: uniqueSeedValue('主任创建用户'),
|
||||
phone: uniquePhone(),
|
||||
password: 'Seed@1234',
|
||||
role: Role.LEADER,
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
departmentId: ctx.fixtures.departmentA1Id,
|
||||
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({
|
||||
name: 'POST /users role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 400,
|
||||
[Role.HOSPITAL_ADMIN]: 400,
|
||||
[Role.DIRECTOR]: 400,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
@ -136,6 +159,34 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
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 () => {
|
||||
const response = await request(ctx.app.getHttpServer()).get('/users');
|
||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||
@ -173,15 +224,6 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
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 () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/users/99999999')
|
||||
@ -190,15 +232,43 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
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())
|
||||
.get(`/users/${ctx.fixtures.users.doctorA3Id}`)
|
||||
.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({
|
||||
name: 'GET /users/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
@ -206,7 +276,7 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
@ -236,19 +306,31 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
expect(response.body.data.name).toBe(nextName);
|
||||
});
|
||||
|
||||
it('成功:DIRECTOR 可更新本科室医生姓名', async () => {
|
||||
const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
|
||||
it('成功:HOSPITAL_ADMIN 可更新本院医生姓名', async () => {
|
||||
const created = await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
|
||||
const nextName = uniqueSeedValue('主任更新医生名');
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.patch(`/users/${created.id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({ name: nextName });
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
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 () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.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())
|
||||
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||
.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())
|
||||
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
|
||||
.send({ departmentId: ctx.fixtures.departmentA2Id });
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.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({
|
||||
name: 'PATCH /users/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
@ -319,11 +401,21 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
expect(response.body.data.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it('成功:DIRECTOR 可删除本科室医生', async () => {
|
||||
const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
|
||||
it('成功:HOSPITAL_ADMIN 可删除本院医生', async () => {
|
||||
const created = await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.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);
|
||||
expect(response.body.data.id).toBe(created.id);
|
||||
@ -337,30 +429,30 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除');
|
||||
});
|
||||
|
||||
it('失败:DIRECTOR 删除非本科室医生返回 403', async () => {
|
||||
it('失败:DIRECTOR 删除用户返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.delete(`/users/${ctx.fixtures.users.doctorA3Id}`)
|
||||
.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, '无权限执行当前操作');
|
||||
});
|
||||
|
||||
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({
|
||||
name: 'DELETE /users/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
|
||||
@ -17,4 +17,5 @@ module.exports = {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
maxWorkers: 1,
|
||||
testTimeout: 30000,
|
||||
};
|
||||
|
||||