Compare commits
No commits in common. "2bfe8ac8c830494b690793a360c98de05a81eb93" and "64d1ad78965d95972d045d3a781799bdf55262eb" have entirely different histories.
2bfe8ac8c8
...
64d1ad7896
@ -2,57 +2,17 @@
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 提供“全局植入物目录”管理,供患者手术表单选择。
|
||||
- 维护患者手术下的植入实例记录。
|
||||
- 支持为可调压器械配置挡位列表。
|
||||
- 支持管理员按医院、患者、状态和关键词分页查询患者植入实例。
|
||||
- 提供 B 端设备 CRUD。
|
||||
- 管理设备与患者的归属关系。
|
||||
- 支持管理员按医院、患者、状态和关键词分页查询设备。
|
||||
|
||||
## 2. 设备实例
|
||||
## 2. 权限
|
||||
|
||||
`Device` 现在表示“患者某次手术下的植入设备实例”,不是独立库存主数据。
|
||||
- `SYSTEM_ADMIN`:可跨院查询和维护设备。
|
||||
- `HOSPITAL_ADMIN`:仅可操作本院患者名下设备。
|
||||
- 其他角色:默认拒绝。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `patientId`:归属患者
|
||||
- `surgeryId`:归属手术,可为空
|
||||
- `implantCatalogId`:型号字典 ID,可为空
|
||||
- `implantModel` / `implantManufacturer` / `implantName`:历史快照
|
||||
- `isPressureAdjustable`:是否可调压
|
||||
- `isAbandoned`:是否弃用
|
||||
- `currentPressure`:当前压力挡位标签
|
||||
- `status`:设备状态
|
||||
|
||||
补充:
|
||||
|
||||
- `currentPressure` 不允许在创建/编辑设备实例时手工指定。
|
||||
- 新植入设备默认以 `initialPressure`(或系统默认值 `0`)作为当前压力起点,后续只允许在调压任务完成时更新。
|
||||
- 发布调压任务时不会立刻修改 `currentPressure`,只有任务完成后才会把目标挡位回写到设备。
|
||||
|
||||
## 3. 植入物目录
|
||||
|
||||
新增 `ImplantCatalog`:
|
||||
|
||||
- `modelCode`:型号编码,唯一
|
||||
- `manufacturer`:厂商
|
||||
- `name`:名称
|
||||
- `pressureLevels`:可调压器械的挡位字符串标签列表
|
||||
- `isPressureAdjustable`:是否可调压
|
||||
- `notes`:目录备注
|
||||
|
||||
可见性:
|
||||
|
||||
- 全部已登录 B 端角色都可读取,用于患者手术录入
|
||||
- 仅 `SYSTEM_ADMIN` 可做目录 CRUD
|
||||
- 目录是全局共享的,不按医院隔离
|
||||
|
||||
说明:
|
||||
|
||||
- 挡位列表按字符串标签保存,例如 `["0.5", "1", "1.5"]` 或 `["10", "20", "30"]`。
|
||||
- 保存前会自动标准化并去重排序,例如 `["01.0", "1.50", "1"]` 最终会整理为 `["1", "1.5"]`。
|
||||
|
||||
## 4. 接口
|
||||
|
||||
设备实例:
|
||||
## 3. 接口
|
||||
|
||||
- `GET /b/devices`:分页查询设备列表
|
||||
- `GET /b/devices/:id`:查询设备详情
|
||||
@ -60,18 +20,8 @@
|
||||
- `PATCH /b/devices/:id`:更新设备
|
||||
- `DELETE /b/devices/:id`:删除设备
|
||||
|
||||
型号字典:
|
||||
|
||||
- `GET /b/devices/catalogs`:查询植入物型号字典
|
||||
- `POST /b/devices/catalogs`:新增植入物目录
|
||||
- `PATCH /b/devices/catalogs/:id`:更新植入物目录
|
||||
- `DELETE /b/devices/catalogs/:id`:删除植入物目录
|
||||
|
||||
## 5. 约束
|
||||
## 4. 约束
|
||||
|
||||
- 设备必须绑定到一个患者。
|
||||
- 设备 SN 在全库唯一,服务端会统一转成大写后再校验。
|
||||
- 删除已被任务明细引用的设备会返回 `409`。
|
||||
- 删除已被患者手术引用的植入物目录会返回 `409`。
|
||||
- 可调压植入物若配置了 `pressureLevels`,患者手术录入和任务调压时的压力值必须命中该挡位列表。
|
||||
- 调压任务仅允许针对 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备发布。
|
||||
- `Device.currentPressure` 只允许由调压任务完成时更新,患者手术录入和设备实例编辑都不开放手工写入。
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
# 系统字典说明(`src/dictionaries`)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 将患者手术表单中的固定选项沉淀为系统级字典。
|
||||
- 仅允许 `SYSTEM_ADMIN` 做 CRUD。
|
||||
- 业务角色仅可读取启用中的字典项,用于患者录入表单。
|
||||
|
||||
## 2. 当前字典类型
|
||||
|
||||
- `PRIMARY_DISEASE`:原发病
|
||||
- `HYDROCEPHALUS_TYPE`:脑积水类型
|
||||
- `SHUNT_MODE`:分流方式
|
||||
- `PROXIMAL_PUNCTURE_AREA`:近端穿刺区域
|
||||
- `VALVE_PLACEMENT_SITE`:阀门植入部位
|
||||
- `DISTAL_SHUNT_DIRECTION`:远端分流方向
|
||||
|
||||
## 3. 数据结构
|
||||
|
||||
新增 `DictionaryItem`:
|
||||
|
||||
- `type`:字典类型枚举
|
||||
- `label`:字典项显示值
|
||||
- `sortOrder`:排序值,越小越靠前
|
||||
- `enabled`:是否启用
|
||||
|
||||
约束:
|
||||
|
||||
- 同一 `type` 下 `label` 唯一。
|
||||
- 非系统管理员读取时只返回 `enabled=true` 的字典项。
|
||||
|
||||
## 4. 接口
|
||||
|
||||
- `GET /b/dictionaries`:查询字典项
|
||||
- `POST /b/dictionaries`:创建字典项(仅系统管理员)
|
||||
- `PATCH /b/dictionaries/:id`:更新字典项(仅系统管理员)
|
||||
- `DELETE /b/dictionaries/:id`:删除字典项(仅系统管理员)
|
||||
|
||||
说明:
|
||||
|
||||
- `GET /b/dictionaries?includeDisabled=true` 仅系统管理员生效。
|
||||
- 患者手术表单现在从该接口动态读取选项,不再使用前端硬编码数组。
|
||||
@ -4,17 +4,17 @@
|
||||
|
||||
- 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。
|
||||
- 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。
|
||||
- 测试前固定执行数据库重置,并通过真实接口全流程建数,确保结果可重复。
|
||||
- 测试前固定执行数据库重置与 seed,确保结果可重复。
|
||||
|
||||
## 2. 风险提示
|
||||
|
||||
`pnpm test:e2e` 会执行:
|
||||
|
||||
1. `prisma migrate reset --force`
|
||||
2. 启动 Jest 后,由测试用例通过真实 HTTP 接口完成基础夹具创建
|
||||
2. `node prisma/seed.mjs`
|
||||
|
||||
这会清空 `.env` 中 `DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
|
||||
另外,接口引导创建的测试账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
|
||||
另外,seed 账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
|
||||
|
||||
## 3. 运行命令
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
仅重置数据库并重新生成 Prisma Client:
|
||||
仅重置数据库并注入 seed:
|
||||
|
||||
```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,37 +42,20 @@ 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,11 +5,10 @@
|
||||
- 登录页:`/auth/login`,支持可选 `hospitalId`。
|
||||
- 首页看板:按角色拉取组织与患者统计。
|
||||
- 设备页:新增管理员专用设备 CRUD,复用真实设备接口。
|
||||
- 任务页:改为只读调压记录页,接入真实任务列表接口。
|
||||
- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。
|
||||
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
|
||||
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`),
|
||||
后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。
|
||||
- 新增影像库页:接入真实上传接口,支持图片/视频/文件上传与分页查看。
|
||||
后端直接保存身份证号原文,不再做哈希转换。
|
||||
|
||||
## 2. 接口契约对齐点
|
||||
|
||||
@ -17,27 +16,23 @@
|
||||
- `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. 角色权限提示
|
||||
|
||||
- 任务接口权限:
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时必须指定接收工程师;可取消自己创建的任务
|
||||
- `ENGINEER`:仅可完成分配给自己的任务
|
||||
- `DOCTOR/DIRECTOR/LEADER`:发布、取消(仅可取消自己创建的任务)
|
||||
- `ENGINEER`:接收、完成
|
||||
- 患者列表权限:
|
||||
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
||||
- 用户管理接口:
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可创建、编辑、删除
|
||||
- `DIRECTOR` 可只读查看本科室下级医生/组长
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR` 可访问列表与创建
|
||||
- `DIRECTOR` 页面语义调整为“医生管理”,仅管理本科室医生
|
||||
- 工程师绑定医院仅 `SYSTEM_ADMIN`
|
||||
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`HOSPITAL_ADMIN` 可删除本院无关联、且非管理员用户
|
||||
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`DIRECTOR` 可删除本科室无关联医生
|
||||
|
||||
## 3.1 结构图页面交互调整
|
||||
|
||||
@ -51,29 +46,27 @@
|
||||
`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`
|
||||
- `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问
|
||||
- 仅 `DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问
|
||||
- `patients`
|
||||
- `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问
|
||||
|
||||
患者页负责发起调压任务并指定接收人,任务页仅查看调压记录,不再提供发布或接收入口。
|
||||
|
||||
患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。
|
||||
|
||||
前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。
|
||||
|
||||
## 3.3 主任/组长组织管理范围
|
||||
|
||||
- `DIRECTOR`
|
||||
- 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
|
||||
- 可查看组织架构、小组列表(限定本科室范围)
|
||||
- 可创建/编辑/删除本科室下小组
|
||||
- 可进入“医生管理”页,创建/维护本科室医生
|
||||
- `LEADER`
|
||||
- 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
|
||||
- 主任/组长不再显示“科室管理”“小组管理”“用户管理”页面。
|
||||
- 可查看组织架构、小组列表(限定本科室/本小组范围)
|
||||
- 可编辑本小组名称
|
||||
- 主任/组长不再显示独立“科室管理”页面。
|
||||
- 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。
|
||||
|
||||
## 4. 本地运行
|
||||
|
||||
126
docs/patients.md
@ -2,109 +2,33 @@
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- B 端:按组织与角色范围查询患者,并维护患者基础档案。
|
||||
- B 端:支持患者首术录入、二次手术追加、旧设备弃用标记。
|
||||
- 数据关系升级为 `Patient -> PatientSurgery -> Device -> TaskItem -> Task`。
|
||||
- C 端:按 `phone + idCard` 做跨院生命周期聚合,返回手术事件与调压事件。
|
||||
- B 端:按组织与角色范围查询患者(强依赖 `hospitalId`)。
|
||||
- C 端:按 `phone + idCard` 做跨院聚合查询。
|
||||
- 患者档案直接保存身份证号原文,不再做哈希转换。
|
||||
- 服务端只做轻量格式整理:去空格、统一末尾 `x/X` 为大写。
|
||||
|
||||
## 2. 患者基础档案
|
||||
|
||||
患者表新增以下字段:
|
||||
|
||||
- `inpatientNo`:住院号
|
||||
- `projectName`:项目名称
|
||||
- `phone`:联系电话
|
||||
- `idCard`:身份证号原文
|
||||
- `doctorId`:患者归属人员(医生/主任/组长)
|
||||
|
||||
说明:
|
||||
|
||||
- `name` 仍然保留为患者姓名必填字段。
|
||||
- `doctorId + hospitalId` 仍然是患者的组织归属来源,不直接绑定小组/科室。
|
||||
- 手术表单中的 `原发病 / 脑积水类型 / 分流方式 / 近端穿刺区域 / 阀门植入部位 / 远端分流方向` 改为读取系统字典,不再前端硬编码。
|
||||
|
||||
## 3. 手术档案
|
||||
|
||||
新增 `PatientSurgery` 表,每次手术保存:
|
||||
|
||||
- `surgeryDate`:手术日期
|
||||
- `surgeryName`:手术名称
|
||||
- `surgeonId`:主刀医生账号 ID(自动等于患者归属医生)
|
||||
- `surgeonName`:主刀医生姓名快照
|
||||
- `preOpPressure`:术前测压,可为空
|
||||
- `primaryDisease`:原发病
|
||||
- `hydrocephalusTypes`:脑积水类型,多选
|
||||
- `previousShuntSurgeryDate`:上次分流手术时间,可为空
|
||||
- `preOpMaterials`:术前 CT 影像/资料,保存为附件元数据数组
|
||||
- `notes`:手术备注,可为空
|
||||
|
||||
返回时会自动补充:
|
||||
|
||||
- `shuntSurgeryCount`:当前这台手术是该患者第几次分流手术
|
||||
- `activeDeviceCount`:本次手术仍在用设备数
|
||||
- `abandonedDeviceCount`:本次手术已弃用设备数
|
||||
|
||||
说明:
|
||||
|
||||
- 新增/修改手术时,前端不再单独提交主刀医生。
|
||||
- 后端会直接使用患者当前的 `doctorId` 作为 `surgeonId`,并保存当时的 `surgeonName` 快照。
|
||||
- 如果后续患者归属医生发生变化,只会影响后续新建手术;历史手术仍保留创建当时的主刀医生快照。
|
||||
|
||||
## 4. 植入设备
|
||||
|
||||
设备表仍沿用 `Device`,但语义改为“患者手术下植入的设备实例”。
|
||||
|
||||
新增/使用字段:
|
||||
|
||||
- `implantCatalogId`:植入物型号字典 ID
|
||||
- `implantModel` / `implantManufacturer` / `implantName`:型号快照
|
||||
- `isPressureAdjustable`:是否可调压
|
||||
- `isAbandoned`:是否已弃用
|
||||
- `shuntMode`:分流方式
|
||||
- `proximalPunctureAreas`:近端穿刺区域,最多 2 个
|
||||
- `valvePlacementSites`:阀门植入部位,最多 2 个
|
||||
- `distalShuntDirection`:远端分流方向
|
||||
- `initialPressure`:初始压力挡位标签,可为空
|
||||
- `implantNotes`:植入物备注,可为空
|
||||
- `labelImageUrl`:植入物标签图片地址,可为空
|
||||
|
||||
说明:
|
||||
|
||||
- 患者手术里选择的是“全局植入物目录”,不是按医院单独维护的设备库。
|
||||
- 同一个植入物目录可被多个患者手术重复绑定,患者侧保存的是目录快照。
|
||||
- 旧设备弃用后,`TaskItem` 历史不会删除。
|
||||
- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。
|
||||
- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。
|
||||
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的字符串标签值,例如 `0.5 / 1 / 1.5 / 10 / 20 / 30`。
|
||||
- 挡位标签保存前会自动标准化,例如 `01.0 -> 1`、`1.50 -> 1.5`。
|
||||
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`;发布调压任务时不会立刻改当前压力,只有工程师完成任务后才会更新。
|
||||
- 术前资料与植入物标签现在支持直接上传;上传成功后,患者详情会按图片/视频做预览,不再只是裸链接。
|
||||
|
||||
## 5. B 端可见性
|
||||
## 2. B 端可见性
|
||||
|
||||
- `DOCTOR`:仅可查自己名下患者
|
||||
- `LEADER`:可查本组医生名下患者
|
||||
- `DIRECTOR`:可查本科室医生名下患者
|
||||
- `LEADER`:可查本组医生名下患者(按医生当前 `groupId` 反查)
|
||||
- `DIRECTOR`:可查本科室医生名下患者(按医生当前 `departmentId` 反查)
|
||||
- `HOSPITAL_ADMIN`:可查本院全部患者
|
||||
- `SYSTEM_ADMIN`:需显式传入目标 `hospitalId`
|
||||
|
||||
## 6. B 端接口
|
||||
## 2.1 B 端 CRUD
|
||||
|
||||
- `GET /b/patients`:按角色查询可见患者列表
|
||||
- `GET /b/patients/doctors`:查询当前角色可见的归属人员候选
|
||||
- `POST /b/patients`:创建患者,可选带 `initialSurgery`
|
||||
- `POST /b/patients/:id/surgeries`:为患者新增手术
|
||||
- `GET /b/patients`:按角色查询可见患者
|
||||
- `GET /b/patients/doctors`:查询当前角色可见的归属人员候选(医生/主任/组长,用于患者表单)
|
||||
- `POST /b/patients`:创建患者
|
||||
- `GET /b/patients/:id`:查询患者详情
|
||||
- `PATCH /b/patients/:id`:更新患者基础信息
|
||||
- `DELETE /b/patients/:id`:删除患者
|
||||
- `PATCH /b/patients/:id`:更新患者
|
||||
- `DELETE /b/patients/:id`:删除患者(若存在关联设备返回 409)
|
||||
|
||||
约束:
|
||||
说明:
|
||||
患者表只绑定 `doctorId + hospitalId`,不直接绑定小组/科室。医生调组或调科后,
|
||||
可见范围会按医生当前组织归属自动变化,无需迁移患者数据。
|
||||
|
||||
- `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。
|
||||
- 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`。
|
||||
- 患者建档时会自动记录 `creator`,列表和详情都会返回创建人信息。
|
||||
|
||||
## 7. C 端生命周期聚合
|
||||
## 3. C 端生命周期聚合
|
||||
|
||||
接口:`GET /c/patients/lifecycle?phone=...&idCard=...`
|
||||
|
||||
@ -112,20 +36,10 @@
|
||||
|
||||
1. 不做医院隔离(跨租户)
|
||||
2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配
|
||||
3. 聚合 `Patient -> PatientSurgery -> Device` 的手术事件
|
||||
4. 聚合 `Patient -> Device -> TaskItem -> Task` 的调压事件
|
||||
5. 全部事件按 `occurredAt DESC` 返回
|
||||
3. 关联查询 `Patient -> Device -> TaskItem -> Task`
|
||||
4. 返回扁平生命周期列表(按 `Task.createdAt DESC`)
|
||||
|
||||
事件类型:
|
||||
|
||||
- `SURGERY`
|
||||
- `TASK_PRESSURE_ADJUSTMENT`
|
||||
|
||||
说明:
|
||||
|
||||
- 生命周期中的 `initialPressure / currentPressure / oldPressure / targetPressure` 均返回字符串挡位标签。
|
||||
|
||||
## 8. 响应结构
|
||||
## 4. 响应结构
|
||||
|
||||
全部接口统一返回:
|
||||
|
||||
|
||||
@ -7,47 +7,34 @@
|
||||
|
||||
## 2. 状态机
|
||||
|
||||
- 当前发布流程:`ACCEPTED -> COMPLETED`
|
||||
- 当前取消流程:`ACCEPTED -> CANCELLED`
|
||||
- 历史兼容:保留 `PENDING` 状态枚举,便于兼容旧数据与旧任务记录
|
||||
- `PENDING -> ACCEPTED -> COMPLETED`
|
||||
- `PENDING/ACCEPTED -> CANCELLED`
|
||||
|
||||
非法流转会返回 `409` 冲突错误(中文消息)。
|
||||
|
||||
## 3. 角色权限
|
||||
|
||||
- 系统管理员/医院管理员/医生/主任/组长:发布任务时必须直接指定接收工程师,并且只能取消自己创建的任务
|
||||
- 工程师:不能再执行“接收”动作,只能完成指派给自己的任务
|
||||
- 医生/主任/组长:发布任务、取消自己创建的任务
|
||||
- 工程师:接收任务、完成自己接收的任务
|
||||
- 其他角色:默认拒绝
|
||||
|
||||
补充:
|
||||
|
||||
- `GET /b/tasks/engineers`:返回当前角色可选的接收工程师列表,系统管理员可按医院筛选。
|
||||
- `GET /b/tasks`:返回当前角色可见的调压记录列表,系统管理员可按医院筛选。
|
||||
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
|
||||
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
|
||||
|
||||
## 4. 记录列表
|
||||
|
||||
- 后台任务页不再承担手工发布入口,只展示调压记录。
|
||||
- 记录维度按 `TaskItem` 展开,每条记录会携带:
|
||||
- 任务状态
|
||||
- 患者信息
|
||||
- 手术名称
|
||||
- 设备信息
|
||||
- 旧压力 / 目标压力 / 当前压力(均为字符串挡位标签)
|
||||
- 创建人 / 接收人 / 发布时间
|
||||
|
||||
## 5. 事件触发
|
||||
## 4. 事件触发
|
||||
|
||||
状态变化后会发出事件:
|
||||
|
||||
- `task.published`
|
||||
- `task.accepted`
|
||||
- `task.completed`
|
||||
- `task.cancelled`
|
||||
|
||||
用于后续接入微信通知或消息中心。
|
||||
|
||||
## 6. 完成任务时的设备同步
|
||||
## 5. 完成任务时的设备同步
|
||||
|
||||
`completeTask` 在单事务中执行:
|
||||
|
||||
@ -56,8 +43,3 @@
|
||||
3. 批量更新关联 `Device.currentPressure`
|
||||
|
||||
确保任务状态与设备压力一致性。
|
||||
|
||||
补充:
|
||||
|
||||
- `publishTask` 只负责生成任务和目标挡位,不会立刻修改设备当前压力。
|
||||
- 只有工程师完成任务后,目标挡位才会回写到设备实例。
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
# 上传资产模块说明(`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,10 +18,8 @@
|
||||
|
||||
- 医院内数据按 `hospitalId` 强隔离。
|
||||
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可执行用户创建、编辑、删除。
|
||||
- `DIRECTOR` 仅可只读查看本科室下级医生/组长。
|
||||
- `LEADER` 仅可只读查看本小组医生列表。
|
||||
- `HOSPITAL_ADMIN` 仅可操作本院非管理员账号。
|
||||
- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。
|
||||
- `DIRECTOR` 可创建、查看、编辑、删除本科室医生,但不能跨科室操作,也不能把医生改成其他角色。
|
||||
- 用户组织字段校验:
|
||||
- 院管/医生/工程师等需有医院归属;
|
||||
- 主任/组长需有科室/小组等必要归属;
|
||||
@ -34,22 +32,12 @@
|
||||
- `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`:删除本院无关联、且非管理员用户
|
||||
|
||||
其中主任侧的常用链路为:
|
||||
|
||||
- `GET /users`:查看本科室下级医生/组长
|
||||
- `GET /users/:id`:查看本科室下级详情
|
||||
|
||||
其中组长侧的常用链路为:
|
||||
|
||||
- `GET /users`:查看本小组医生列表
|
||||
- `GET /users/:id`:查看本小组医生详情
|
||||
- `POST /users`:创建本科室医生
|
||||
- `GET /users/:id`:查看本科室医生详情
|
||||
- `PATCH /users/:id`:修改本科室医生信息
|
||||
- `DELETE /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",
|
||||
"test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate && node prisma/seed.mjs",
|
||||
"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,13 +30,10 @@
|
||||
"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": {
|
||||
@ -47,7 +44,6 @@
|
||||
"@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,15 +44,9 @@ 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
|
||||
@ -62,9 +56,6 @@ 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)
|
||||
@ -90,9 +81,6 @@ 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
|
||||
@ -354,10 +342,6 @@ 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
|
||||
@ -387,159 +371,6 @@ 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'}
|
||||
@ -1101,12 +932,6 @@ 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==}
|
||||
|
||||
@ -1322,10 +1147,6 @@ 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:
|
||||
@ -1547,9 +1368,6 @@ 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'}
|
||||
@ -1755,10 +1573,6 @@ 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'}
|
||||
@ -1819,10 +1633,6 @@ 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==}
|
||||
|
||||
@ -1926,10 +1736,6 @@ 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'}
|
||||
@ -2090,16 +1896,9 @@ 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'}
|
||||
@ -2666,9 +2465,6 @@ 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'}
|
||||
@ -2843,10 +2639,6 @@ 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==}
|
||||
|
||||
@ -2986,10 +2778,6 @@ 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'}
|
||||
@ -3703,13 +3491,6 @@ 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
|
||||
@ -3740,102 +3521,6 @@ 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)':
|
||||
@ -4562,12 +4247,6 @@ 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
|
||||
@ -4773,12 +4452,6 @@ 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
|
||||
@ -5026,8 +4699,6 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001778: {}
|
||||
|
||||
caseless@0.12.0: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@ -5193,8 +4864,6 @@ snapshots:
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
detect-newline@3.1.0: {}
|
||||
|
||||
dezalgo@1.0.4:
|
||||
@ -5244,8 +4913,6 @@ 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
|
||||
@ -5368,15 +5035,6 @@ 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
|
||||
@ -5571,19 +5229,8 @@ 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:
|
||||
@ -6279,8 +5926,6 @@ snapshots:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
parse-cache-control@1.0.1: {}
|
||||
|
||||
parse-json@5.2.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
@ -6431,8 +6076,6 @@ snapshots:
|
||||
- react
|
||||
- react-dom
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
proper-lockfile@4.1.2:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@ -6580,37 +6223,6 @@ 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
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
onlyBuiltDependencies:
|
||||
- ffmpeg-static
|
||||
- sharp
|
||||
@ -1,82 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Device" ADD COLUMN "distalShuntDirection" TEXT,
|
||||
ADD COLUMN "implantCatalogId" INTEGER,
|
||||
ADD COLUMN "implantManufacturer" TEXT,
|
||||
ADD COLUMN "implantModel" TEXT,
|
||||
ADD COLUMN "implantName" TEXT,
|
||||
ADD COLUMN "implantNotes" TEXT,
|
||||
ADD COLUMN "initialPressure" INTEGER,
|
||||
ADD COLUMN "isAbandoned" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "isPressureAdjustable" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "labelImageUrl" TEXT,
|
||||
ADD COLUMN "proximalPunctureAreas" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "shuntMode" TEXT,
|
||||
ADD COLUMN "surgeryId" INTEGER,
|
||||
ADD COLUMN "valvePlacementSites" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Patient" ADD COLUMN "inpatientNo" TEXT,
|
||||
ADD COLUMN "projectName" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PatientSurgery" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"patientId" INTEGER NOT NULL,
|
||||
"surgeryDate" TIMESTAMP(3) NOT NULL,
|
||||
"surgeryName" TEXT NOT NULL,
|
||||
"surgeonName" TEXT NOT NULL,
|
||||
"preOpPressure" INTEGER,
|
||||
"primaryDisease" TEXT NOT NULL,
|
||||
"hydrocephalusTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"previousShuntSurgeryDate" TIMESTAMP(3),
|
||||
"preOpMaterials" JSONB,
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PatientSurgery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImplantCatalog" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"modelCode" TEXT NOT NULL,
|
||||
"manufacturer" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"hospitalId" INTEGER,
|
||||
"isPressureAdjustable" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "ImplantCatalog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PatientSurgery_patientId_surgeryDate_idx" ON "PatientSurgery"("patientId", "surgeryDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ImplantCatalog_modelCode_key" ON "ImplantCatalog"("modelCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImplantCatalog_hospitalId_idx" ON "ImplantCatalog"("hospitalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Device_surgeryId_idx" ON "Device"("surgeryId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Device_implantCatalogId_idx" ON "Device"("implantCatalogId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Device_patientId_isAbandoned_idx" ON "Device"("patientId", "isAbandoned");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Patient_inpatientNo_idx" ON "Patient"("inpatientNo");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PatientSurgery" ADD CONSTRAINT "PatientSurgery_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImplantCatalog" ADD CONSTRAINT "ImplantCatalog_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Device" ADD CONSTRAINT "Device_surgeryId_fkey" FOREIGN KEY ("surgeryId") REFERENCES "PatientSurgery"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Device" ADD CONSTRAINT "Device_implantCatalogId_fkey" FOREIGN KEY ("implantCatalogId") REFERENCES "ImplantCatalog"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -1,21 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DictionaryType" AS ENUM ('PRIMARY_DISEASE', 'HYDROCEPHALUS_TYPE', 'SHUNT_MODE', 'PROXIMAL_PUNCTURE_AREA', 'VALVE_PLACEMENT_SITE', 'DISTAL_SHUNT_DIRECTION');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DictionaryItem" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"type" "DictionaryType" NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DictionaryItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DictionaryItem_type_enabled_sortOrder_idx" ON "DictionaryItem"("type", "enabled", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DictionaryItem_type_label_key" ON "DictionaryItem"("type", "label");
|
||||
@ -1,15 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ImplantCatalog"
|
||||
ADD COLUMN "pressureLevels" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
|
||||
ADD COLUMN "notes" TEXT,
|
||||
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ImplantCatalog" DROP CONSTRAINT IF EXISTS "ImplantCatalog_hospitalId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "ImplantCatalog_hospitalId_idx";
|
||||
|
||||
-- DropColumn
|
||||
ALTER TABLE "ImplantCatalog" DROP COLUMN IF EXISTS "hospitalId";
|
||||
@ -1,19 +0,0 @@
|
||||
-- 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;
|
||||
@ -1,5 +0,0 @@
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "Device_snCode_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Device" DROP COLUMN "snCode";
|
||||
@ -1,19 +0,0 @@
|
||||
-- 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;
|
||||
@ -1,34 +0,0 @@
|
||||
-- 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;
|
||||
@ -36,23 +36,6 @@ enum TaskStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
// 医学字典类型:驱动患者手术表单中的单选/多选项。
|
||||
enum DictionaryType {
|
||||
PRIMARY_DISEASE
|
||||
HYDROCEPHALUS_TYPE
|
||||
SHUNT_MODE
|
||||
PROXIMAL_PUNCTURE_AREA
|
||||
VALVE_PLACEMENT_SITE
|
||||
DISTAL_SHUNT_DIRECTION
|
||||
}
|
||||
|
||||
// 上传资产类型:用于图库/视频库分类。
|
||||
enum UploadAssetType {
|
||||
IMAGE
|
||||
VIDEO
|
||||
FILE
|
||||
}
|
||||
|
||||
// 医院主表:多租户顶层实体。
|
||||
model Hospital {
|
||||
id Int @id @default(autoincrement())
|
||||
@ -61,7 +44,6 @@ model Hospital {
|
||||
users User[]
|
||||
patients Patient[]
|
||||
tasks Task[]
|
||||
uploads UploadAsset[]
|
||||
}
|
||||
|
||||
// 科室表:归属于医院。
|
||||
@ -89,28 +71,25 @@ 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")
|
||||
createdPatients Patient[] @relation("PatientCreator")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
acceptedTasks Task[] @relation("TaskEngineer")
|
||||
surgeonSurgeries PatientSurgery[] @relation("SurgerySurgeon")
|
||||
createdUploads UploadAsset[] @relation("UploadCreator")
|
||||
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
|
||||
doctorPatients Patient[] @relation("DoctorPatients")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
acceptedTasks Task[] @relation("TaskEngineer")
|
||||
|
||||
@@unique([phone, role, hospitalId])
|
||||
@@index([phone])
|
||||
@ -121,139 +100,32 @@ model User {
|
||||
|
||||
// 患者表:院内患者档案,按医院隔离。
|
||||
model Patient {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
// 住院号:用于院内患者检索与病案关联。
|
||||
inpatientNo String?
|
||||
// 项目名称:用于区分患者所属项目/课题。
|
||||
projectName String?
|
||||
phone String
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
phone String
|
||||
// 患者身份证号,录入与查询都使用原始证件号。
|
||||
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[]
|
||||
idCard String
|
||||
hospitalId Int
|
||||
doctorId Int
|
||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
|
||||
devices Device[]
|
||||
|
||||
@@index([phone, idCard])
|
||||
@@index([hospitalId, doctorId])
|
||||
@@index([inpatientNo])
|
||||
@@index([creatorId])
|
||||
}
|
||||
|
||||
// 患者手术表:保存每次分流/复手术档案。
|
||||
model PatientSurgery {
|
||||
id Int @id @default(autoincrement())
|
||||
patientId Int
|
||||
surgeryDate DateTime
|
||||
surgeryName String
|
||||
surgeonId Int?
|
||||
surgeonName String
|
||||
// 术前测压:部分患者可为空。
|
||||
preOpPressure Int?
|
||||
// 原发病:前端单选,后端先按字符串存储,方便后续补字典。
|
||||
primaryDisease String
|
||||
// 脑积水类型:前端多选。
|
||||
hydrocephalusTypes String[] @default([])
|
||||
// 上次分流手术时间:无既往分流史时为空。
|
||||
previousShuntSurgeryDate DateTime?
|
||||
// 术前影像/资料:支持图片、视频等附件元数据。
|
||||
preOpMaterials Json?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||
surgeon User? @relation("SurgerySurgeon", fields: [surgeonId], references: [id], onDelete: SetNull)
|
||||
devices Device[]
|
||||
|
||||
@@index([patientId, surgeryDate])
|
||||
@@index([surgeonId])
|
||||
}
|
||||
|
||||
// 植入物型号字典:供前端单选型号后自动回填厂家与名称。
|
||||
model ImplantCatalog {
|
||||
id Int @id @default(autoincrement())
|
||||
modelCode String @unique
|
||||
manufacturer String
|
||||
name String
|
||||
// 可调压器械的可选挡位,由系统管理员维护。
|
||||
pressureLevels String[] @default([])
|
||||
isPressureAdjustable Boolean @default(true)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
devices Device[]
|
||||
}
|
||||
|
||||
// 系统级字典项:由系统管理员维护,供患者手术表单选择使用。
|
||||
model DictionaryItem {
|
||||
id Int @id @default(autoincrement())
|
||||
type DictionaryType
|
||||
label String
|
||||
sortOrder Int @default(0)
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([type, label])
|
||||
@@index([type, enabled, sortOrder])
|
||||
}
|
||||
|
||||
// 上传资产表:保存图片/视频/文件元数据,供图库与患者表单复用。
|
||||
model 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())
|
||||
currentPressure String
|
||||
status DeviceStatus @default(ACTIVE)
|
||||
patientId Int
|
||||
surgeryId Int?
|
||||
implantCatalogId Int?
|
||||
// 植入物快照:避免型号字典修改后影响历史病历。
|
||||
implantModel String?
|
||||
implantManufacturer String?
|
||||
implantName String?
|
||||
isPressureAdjustable Boolean @default(true)
|
||||
// 二次手术后旧设备可标记弃用,但历史调压任务仍需保留。
|
||||
isAbandoned Boolean @default(false)
|
||||
shuntMode String?
|
||||
proximalPunctureAreas String[] @default([])
|
||||
valvePlacementSites String[] @default([])
|
||||
distalShuntDirection String?
|
||||
initialPressure String?
|
||||
implantNotes String?
|
||||
labelImageUrl String?
|
||||
patient Patient @relation(fields: [patientId], references: [id])
|
||||
surgery PatientSurgery? @relation(fields: [surgeryId], references: [id], onDelete: SetNull)
|
||||
implantCatalog ImplantCatalog? @relation(fields: [implantCatalogId], references: [id], onDelete: SetNull)
|
||||
taskItems TaskItem[]
|
||||
id Int @id @default(autoincrement())
|
||||
snCode String @unique
|
||||
currentPressure Int
|
||||
status DeviceStatus @default(ACTIVE)
|
||||
patientId Int
|
||||
patient Patient @relation(fields: [patientId], references: [id])
|
||||
taskItems TaskItem[]
|
||||
|
||||
@@index([patientId, status])
|
||||
@@index([surgeryId])
|
||||
@@index([implantCatalogId])
|
||||
@@index([patientId, isAbandoned])
|
||||
}
|
||||
|
||||
// 主任务表:记录调压任务主单。
|
||||
@ -277,8 +149,8 @@ model TaskItem {
|
||||
id Int @id @default(autoincrement())
|
||||
taskId Int
|
||||
deviceId Int
|
||||
oldPressure String
|
||||
targetPressure String
|
||||
oldPressure Int
|
||||
targetPressure Int
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
device Device @relation(fields: [deviceId], references: [id])
|
||||
|
||||
|
||||
488
prisma/seed.mjs
@ -3,8 +3,7 @@ import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { hash } from 'bcrypt';
|
||||
import prismaClientPackage from '@prisma/client';
|
||||
|
||||
const { DictionaryType, DeviceStatus, PrismaClient, Role, TaskStatus } =
|
||||
prismaClientPackage;
|
||||
const { DeviceStatus, PrismaClient, Role, TaskStatus } = prismaClientPackage;
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
@ -61,16 +60,7 @@ async function upsertUserByOpenId(openId, data) {
|
||||
});
|
||||
}
|
||||
|
||||
async function ensurePatient({
|
||||
hospitalId,
|
||||
doctorId,
|
||||
creatorId,
|
||||
name,
|
||||
inpatientNo = null,
|
||||
projectName = null,
|
||||
phone,
|
||||
idCard,
|
||||
}) {
|
||||
async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) {
|
||||
const existing = await prisma.patient.findFirst({
|
||||
where: {
|
||||
hospitalId,
|
||||
@ -80,16 +70,10 @@ async function ensurePatient({
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (
|
||||
existing.doctorId !== doctorId ||
|
||||
existing.creatorId !== creatorId ||
|
||||
existing.name !== name ||
|
||||
existing.inpatientNo !== inpatientNo ||
|
||||
existing.projectName !== projectName
|
||||
) {
|
||||
if (existing.doctorId !== doctorId || existing.name !== name) {
|
||||
return prisma.patient.update({
|
||||
where: { id: existing.id },
|
||||
data: { doctorId, creatorId, name, inpatientNo, projectName },
|
||||
data: { doctorId, name },
|
||||
});
|
||||
}
|
||||
return existing;
|
||||
@ -99,183 +83,13 @@ async function ensurePatient({
|
||||
data: {
|
||||
hospitalId,
|
||||
doctorId,
|
||||
creatorId,
|
||||
name,
|
||||
inpatientNo,
|
||||
projectName,
|
||||
phone,
|
||||
idCard,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureImplantCatalog({
|
||||
modelCode,
|
||||
manufacturer,
|
||||
name,
|
||||
pressureLevels = [],
|
||||
isPressureAdjustable = true,
|
||||
notes = null,
|
||||
}) {
|
||||
return prisma.implantCatalog.upsert({
|
||||
where: { modelCode },
|
||||
update: {
|
||||
manufacturer,
|
||||
name,
|
||||
pressureLevels,
|
||||
isPressureAdjustable,
|
||||
notes,
|
||||
},
|
||||
create: {
|
||||
modelCode,
|
||||
manufacturer,
|
||||
name,
|
||||
pressureLevels,
|
||||
isPressureAdjustable,
|
||||
notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureDictionaryItem({
|
||||
type,
|
||||
label,
|
||||
sortOrder = 0,
|
||||
enabled = true,
|
||||
}) {
|
||||
return prisma.dictionaryItem.upsert({
|
||||
where: {
|
||||
type_label: {
|
||||
type,
|
||||
label,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
sortOrder,
|
||||
enabled,
|
||||
},
|
||||
create: {
|
||||
type,
|
||||
label,
|
||||
sortOrder,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensurePatientSurgery({
|
||||
patientId,
|
||||
surgeryDate,
|
||||
surgeryName,
|
||||
surgeonName,
|
||||
preOpPressure = null,
|
||||
primaryDisease,
|
||||
hydrocephalusTypes,
|
||||
previousShuntSurgeryDate = null,
|
||||
preOpMaterials = null,
|
||||
notes = null,
|
||||
}) {
|
||||
const normalizedSurgeryDate = new Date(surgeryDate);
|
||||
const normalizedPreviousDate = previousShuntSurgeryDate
|
||||
? new Date(previousShuntSurgeryDate)
|
||||
: null;
|
||||
|
||||
const existing = await prisma.patientSurgery.findFirst({
|
||||
where: {
|
||||
patientId,
|
||||
surgeryDate: normalizedSurgeryDate,
|
||||
surgeryName,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return prisma.patientSurgery.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
surgeonName,
|
||||
preOpPressure,
|
||||
primaryDisease,
|
||||
hydrocephalusTypes,
|
||||
previousShuntSurgeryDate: normalizedPreviousDate,
|
||||
preOpMaterials,
|
||||
notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.patientSurgery.create({
|
||||
data: {
|
||||
patientId,
|
||||
surgeryDate: normalizedSurgeryDate,
|
||||
surgeryName,
|
||||
surgeonName,
|
||||
preOpPressure,
|
||||
primaryDisease,
|
||||
hydrocephalusTypes,
|
||||
previousShuntSurgeryDate: normalizedPreviousDate,
|
||||
preOpMaterials,
|
||||
notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function 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);
|
||||
|
||||
@ -403,62 +217,10 @@ async function main() {
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
const dictionarySeeds = {
|
||||
[DictionaryType.PRIMARY_DISEASE]: [
|
||||
'先天性脑积水',
|
||||
'梗阻性脑积水',
|
||||
'交通性脑积水',
|
||||
'出血后脑积水',
|
||||
'肿瘤相关脑积水',
|
||||
'外伤后脑积水',
|
||||
'感染后脑积水',
|
||||
'分流功能障碍',
|
||||
],
|
||||
[DictionaryType.HYDROCEPHALUS_TYPE]: [
|
||||
'交通性',
|
||||
'梗阻性',
|
||||
'高压性',
|
||||
'正常压力',
|
||||
'先天性',
|
||||
'继发性',
|
||||
],
|
||||
[DictionaryType.SHUNT_MODE]: ['VPS', 'VPLS', 'LPS', '脑室心房分流'],
|
||||
[DictionaryType.PROXIMAL_PUNCTURE_AREA]: [
|
||||
'额角',
|
||||
'枕角',
|
||||
'三角区',
|
||||
'腰穿',
|
||||
'后角',
|
||||
],
|
||||
[DictionaryType.VALVE_PLACEMENT_SITE]: [
|
||||
'耳后',
|
||||
'胸前',
|
||||
'锁骨下',
|
||||
'腹壁',
|
||||
'腰背部',
|
||||
],
|
||||
[DictionaryType.DISTAL_SHUNT_DIRECTION]: ['腹腔', '胸腔', '心房', '腰大池'],
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(dictionarySeeds).flatMap(([type, labels]) =>
|
||||
labels.map((label, index) =>
|
||||
ensureDictionaryItem({
|
||||
type,
|
||||
label,
|
||||
sortOrder: index * 10,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const patientA1 = await ensurePatient({
|
||||
hospitalId: hospitalA.id,
|
||||
doctorId: doctorA.id,
|
||||
creatorId: doctorA.id,
|
||||
name: 'Seed Patient A1',
|
||||
inpatientNo: 'ZYH-A-0001',
|
||||
projectName: '脑积水随访项目-A',
|
||||
phone: '13800002001',
|
||||
idCard: '110101199001010011',
|
||||
});
|
||||
@ -466,10 +228,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',
|
||||
phone: '13800002002',
|
||||
idCard: '110101199002020022',
|
||||
});
|
||||
@ -477,10 +236,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',
|
||||
phone: '13800002003',
|
||||
idCard: '110101199003030033',
|
||||
});
|
||||
@ -488,190 +244,84 @@ 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',
|
||||
phone: '13800002001',
|
||||
idCard: '110101199001010011',
|
||||
});
|
||||
|
||||
const adjustableCatalog = await ensureImplantCatalog({
|
||||
modelCode: 'SEED-ADJUSTABLE-VALVE',
|
||||
manufacturer: 'Seed MedTech',
|
||||
name: 'Seed 可调压分流阀',
|
||||
pressureLevels: [80, 100, 120, 140, 160],
|
||||
isPressureAdjustable: true,
|
||||
notes: 'Seed 全局可调压目录样例',
|
||||
const deviceA1 = await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-A-001' },
|
||||
update: {
|
||||
patientId: patientA1.id,
|
||||
currentPressure: 118,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-001',
|
||||
patientId: patientA1.id,
|
||||
currentPressure: 118,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
const fixedCatalog = await ensureImplantCatalog({
|
||||
modelCode: 'SEED-FIXED-VALVE',
|
||||
manufacturer: 'Seed MedTech',
|
||||
name: 'Seed 固定压分流阀',
|
||||
pressureLevels: [],
|
||||
isPressureAdjustable: false,
|
||||
notes: 'Seed 固定压目录样例',
|
||||
const deviceA2 = await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-A-002' },
|
||||
update: {
|
||||
patientId: patientA2.id,
|
||||
currentPressure: 112,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-002',
|
||||
patientId: patientA2.id,
|
||||
currentPressure: 112,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
const surgeryA1Old = await ensurePatientSurgery({
|
||||
patientId: patientA1.id,
|
||||
surgeryDate: '2024-06-01T08:00:00.000Z',
|
||||
surgeryName: '首次脑室腹腔分流术',
|
||||
surgeonName: 'Seed Director A',
|
||||
preOpPressure: 24,
|
||||
primaryDisease: '先天性脑积水',
|
||||
hydrocephalusTypes: ['交通性'],
|
||||
notes: '首台手术',
|
||||
await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-A-003' },
|
||||
update: {
|
||||
patientId: patientA3.id,
|
||||
currentPressure: 109,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-003',
|
||||
patientId: patientA3.id,
|
||||
currentPressure: 109,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
const surgeryA1New = await ensurePatientSurgery({
|
||||
patientId: patientA1.id,
|
||||
surgeryDate: '2025-09-10T08:00:00.000Z',
|
||||
surgeryName: '分流系统翻修术',
|
||||
surgeonName: 'Seed Director A',
|
||||
preOpPressure: 18,
|
||||
primaryDisease: '分流功能障碍',
|
||||
hydrocephalusTypes: ['交通性', '高压性'],
|
||||
previousShuntSurgeryDate: '2024-06-01T08:00:00.000Z',
|
||||
preOpMaterials: [
|
||||
{
|
||||
type: 'IMAGE',
|
||||
url: 'https://seed.example.com/a1-ct-preop.png',
|
||||
name: 'Seed A1 术前 CT',
|
||||
},
|
||||
],
|
||||
notes: '二次手术,保留原设备历史',
|
||||
const deviceB1 = await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-B-001' },
|
||||
update: {
|
||||
patientId: patientB1.id,
|
||||
currentPressure: 121,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-B-001',
|
||||
patientId: patientB1.id,
|
||||
currentPressure: 121,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
const surgeryA2 = await ensurePatientSurgery({
|
||||
patientId: patientA2.id,
|
||||
surgeryDate: '2025-12-15T08:00:00.000Z',
|
||||
surgeryName: '脑室腹腔分流术',
|
||||
surgeonName: 'Seed Doctor A2',
|
||||
preOpPressure: 20,
|
||||
primaryDisease: '肿瘤相关脑积水',
|
||||
hydrocephalusTypes: ['梗阻性'],
|
||||
});
|
||||
|
||||
const surgeryA3 = await ensurePatientSurgery({
|
||||
patientId: patientA3.id,
|
||||
surgeryDate: '2025-11-20T08:00:00.000Z',
|
||||
surgeryName: '脑室腹腔分流术',
|
||||
surgeonName: 'Seed Doctor A3',
|
||||
preOpPressure: 21,
|
||||
primaryDisease: '外伤后脑积水',
|
||||
hydrocephalusTypes: ['交通性'],
|
||||
});
|
||||
|
||||
const surgeryB1 = await ensurePatientSurgery({
|
||||
patientId: patientB1.id,
|
||||
surgeryDate: '2025-10-05T08:00:00.000Z',
|
||||
surgeryName: '脑室腹腔分流术',
|
||||
surgeonName: 'Seed Doctor B',
|
||||
preOpPressure: 23,
|
||||
primaryDisease: '出血后脑积水',
|
||||
hydrocephalusTypes: ['高压性'],
|
||||
});
|
||||
|
||||
const deviceA1 = await 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 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 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 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 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',
|
||||
await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-A-004' },
|
||||
update: {
|
||||
patientId: patientA1.id,
|
||||
currentPressure: 130,
|
||||
status: DeviceStatus.INACTIVE,
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-004',
|
||||
patientId: patientA1.id,
|
||||
currentPressure: 130,
|
||||
status: DeviceStatus.INACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
// 清理与种子设备关联的历史任务,保证 seed 可重复执行且生命周期夹具稳定。
|
||||
|
||||
@ -8,8 +8,6 @@ import { AuthModule } from './auth/auth.module.js';
|
||||
import { OrganizationModule } from './organization/organization.module.js';
|
||||
import { NotificationsModule } from './notifications/notifications.module.js';
|
||||
import { DevicesModule } from './devices/devices.module.js';
|
||||
import { DictionariesModule } from './dictionaries/dictionaries.module.js';
|
||||
import { UploadsModule } from './uploads/uploads.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -22,8 +20,6 @@ import { UploadsModule } from './uploads/uploads.module.js';
|
||||
OrganizationModule,
|
||||
NotificationsModule,
|
||||
DevicesModule,
|
||||
DictionariesModule,
|
||||
UploadsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -6,9 +6,7 @@ import type { ActorContext } from '../common/actor-context.js';
|
||||
*/
|
||||
export const CurrentActor = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): ActorContext => {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ actor: ActorContext }>();
|
||||
const request = context.switchToHttp().getRequest<{ actor: ActorContext }>();
|
||||
return request.actor;
|
||||
},
|
||||
);
|
||||
|
||||
@ -29,9 +29,7 @@ export class RolesGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ actor?: { role?: Role } }>();
|
||||
const request = context.switchToHttp().getRequest<{ actor?: { role?: Role } }>();
|
||||
const actorRole = request.actor?.role;
|
||||
if (!actorRole || !requiredRoles.includes(actorRole)) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
|
||||
@ -24,7 +24,10 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
// 非 HttpException 统一记录堆栈,便于定位 500 根因。
|
||||
if (!(exception instanceof HttpException)) {
|
||||
const error = exception as { message?: string; stack?: string };
|
||||
this.logger.error(error?.message ?? 'Unhandled exception', error?.stack);
|
||||
this.logger.error(
|
||||
error?.message ?? 'Unhandled exception',
|
||||
error?.stack,
|
||||
);
|
||||
}
|
||||
|
||||
const status = this.resolveStatus(exception);
|
||||
|
||||
@ -58,22 +58,19 @@ 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_DISABLED: '当前流程不支持工程师接收,请由创建人直接指定接收工程师',
|
||||
ACCEPT_ONLY_PENDING: '仅待指派任务可执行接收',
|
||||
COMPLETE_ONLY_ACCEPTED: '仅已指派任务可执行完成',
|
||||
CANCEL_ONLY_PENDING_ACCEPTED: '仅待指派/已指派任务可取消',
|
||||
ENGINEER_ALREADY_ASSIGNED: '任务已指派给其他工程师',
|
||||
ENGINEER_ONLY_ASSIGNEE: '仅任务接收人可完成任务',
|
||||
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: '当前登录上下文缺少医院信息',
|
||||
@ -92,47 +89,19 @@ export const MESSAGES = {
|
||||
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号',
|
||||
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
|
||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||
SURGERY_ITEMS_REQUIRED: '手术下至少需要录入一个植入设备',
|
||||
SURGERY_NOT_FOUND: '手术记录不存在或无权限访问',
|
||||
IMPLANT_CATALOG_NOT_FOUND: '植入物型号不存在或不在当前医院可见范围内',
|
||||
SURGERY_UPDATE_NOT_SUPPORTED:
|
||||
'患者更新接口不支持直接修改手术,请使用新增手术接口',
|
||||
ABANDON_DEVICE_SCOPE_FORBIDDEN: '仅可弃用当前患者名下设备',
|
||||
},
|
||||
|
||||
DEVICE: {
|
||||
NOT_FOUND: '设备不存在或无权限访问',
|
||||
CURRENT_PRESSURE_INVALID: 'currentPressure 必须是合法挡位标签',
|
||||
SN_CODE_REQUIRED: 'snCode 不能为空',
|
||||
SN_CODE_DUPLICATE: '设备 SN 已存在',
|
||||
CURRENT_PRESSURE_INVALID: 'currentPressure 必须为大于等于 0 的整数',
|
||||
STATUS_INVALID: '设备状态不合法',
|
||||
PATIENT_REQUIRED: 'patientId 必填且必须为整数',
|
||||
PATIENT_NOT_FOUND: '归属患者不存在',
|
||||
PATIENT_SCOPE_FORBIDDEN: '仅可绑定当前权限范围内患者',
|
||||
DELETE_CONFLICT: '设备存在关联任务记录,无法删除',
|
||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||
CATALOG_NOT_FOUND: '植入物型号不存在',
|
||||
CATALOG_MODEL_DUPLICATE: '植入物型号编码已存在',
|
||||
CATALOG_SCOPE_FORBIDDEN: '当前角色无权限维护该植入物型号',
|
||||
CATALOG_DELETE_CONFLICT: '植入物型号已被患者手术引用,无法删除',
|
||||
PRESSURE_LEVEL_INVALID: '压力值不在该植入物配置的挡位范围内',
|
||||
DEVICE_NOT_ADJUSTABLE: '仅可调压设备允许创建调压任务',
|
||||
},
|
||||
|
||||
DICTIONARY: {
|
||||
NOT_FOUND: '字典项不存在',
|
||||
LABEL_REQUIRED: '字典项名称不能为空',
|
||||
DUPLICATE: '同类型下字典项名称已存在',
|
||||
SYSTEM_ADMIN_ONLY_MAINTAIN: '仅系统管理员可维护字典',
|
||||
},
|
||||
|
||||
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: {
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* 将常见布尔输入统一转换为 boolean。
|
||||
*/
|
||||
export const ToBoolean = () =>
|
||||
Transform(({ value }) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1') {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
});
|
||||
@ -60,7 +60,6 @@ export class DepartmentsController {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '查询科室列表' })
|
||||
@ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' })
|
||||
@ -80,7 +79,6 @@ export class DepartmentsController {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '查询科室详情' })
|
||||
@ApiParam({ name: 'id', description: '科室 ID' })
|
||||
@ -95,7 +93,12 @@ export class DepartmentsController {
|
||||
* 更新科室。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '更新科室' })
|
||||
update(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
|
||||
@ -29,26 +29,20 @@ export class DepartmentsService {
|
||||
*/
|
||||
async create(actor: ActorContext, dto: CreateDepartmentDto) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
const hospitalId = this.access.toInt(
|
||||
dto.hospitalId,
|
||||
MESSAGES.ORG.HOSPITAL_ID_REQUIRED,
|
||||
);
|
||||
const hospitalId = this.access.toInt(dto.hospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
await this.access.ensureHospitalExists(hospitalId);
|
||||
this.access.assertHospitalScope(actor, hospitalId);
|
||||
|
||||
return this.prisma.department.create({
|
||||
data: {
|
||||
name: this.access.normalizeName(
|
||||
dto.name,
|
||||
MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED,
|
||||
),
|
||||
name: this.access.normalizeName(dto.name, MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED),
|
||||
hospitalId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询科室列表:院管限定本院。
|
||||
* 查询科室列表:院管限定本院;主任/组长限定本科室。
|
||||
*/
|
||||
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
||||
this.access.assertRole(actor, [
|
||||
@ -56,7 +50,6 @@ export class DepartmentsService {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
]);
|
||||
const paging = this.access.resolvePaging(query);
|
||||
const where: Prisma.DepartmentWhereInput = {};
|
||||
@ -67,11 +60,7 @@ 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 ||
|
||||
actor.role === Role.DOCTOR
|
||||
) {
|
||||
} else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
|
||||
where.id = this.access.requireActorDepartmentId(actor);
|
||||
} else if (query.hospitalId != null) {
|
||||
where.hospitalId = this.access.toInt(
|
||||
@ -84,10 +73,7 @@ export class DepartmentsService {
|
||||
this.prisma.department.count({ where }),
|
||||
this.prisma.department.findMany({
|
||||
where,
|
||||
include: {
|
||||
hospital: true,
|
||||
_count: { select: { users: true, groups: true } },
|
||||
},
|
||||
include: { hospital: true, _count: { select: { users: true, groups: true } } },
|
||||
skip: paging.skip,
|
||||
take: paging.take,
|
||||
orderBy: { id: 'desc' },
|
||||
@ -98,7 +84,7 @@ export class DepartmentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询科室详情:院管仅可查看本院。
|
||||
* 查询科室详情:院管仅可查看本院;主任/组长仅可查看本科室。
|
||||
*/
|
||||
async findOne(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [
|
||||
@ -106,12 +92,8 @@ export class DepartmentsService {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
]);
|
||||
const departmentId = this.access.toInt(
|
||||
id,
|
||||
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
|
||||
);
|
||||
const departmentId = this.access.toInt(id, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
|
||||
const department = await this.prisma.department.findUnique({
|
||||
where: { id: departmentId },
|
||||
include: {
|
||||
@ -124,21 +106,18 @@ export class DepartmentsService {
|
||||
}
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
this.access.assertHospitalScope(actor, department.hospitalId);
|
||||
} else if (
|
||||
actor.role === Role.DIRECTOR ||
|
||||
actor.role === Role.LEADER ||
|
||||
actor.role === Role.DOCTOR
|
||||
) {
|
||||
}
|
||||
if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
|
||||
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
||||
if (department.id !== actorDepartmentId) {
|
||||
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
return department;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新科室:院管仅可修改本院。
|
||||
* 更新科室:院管仅可修改本院;主任/组长仅可修改本科室。
|
||||
*/
|
||||
async update(actor: ActorContext, id: number, dto: UpdateDepartmentDto) {
|
||||
const current = await this.findOne(actor, id);
|
||||
@ -149,10 +128,7 @@ export class DepartmentsService {
|
||||
}
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
data.name = this.access.normalizeName(
|
||||
dto.name,
|
||||
MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED,
|
||||
);
|
||||
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED);
|
||||
}
|
||||
|
||||
return this.prisma.department.update({
|
||||
|
||||
@ -14,7 +14,6 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||
@ -23,10 +22,8 @@ import { Roles } from '../../auth/roles.decorator.js';
|
||||
import { RolesGuard } from '../../auth/roles.guard.js';
|
||||
import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { CreateImplantCatalogDto } from '../dto/create-implant-catalog.dto.js';
|
||||
import { CreateDeviceDto } from '../dto/create-device.dto.js';
|
||||
import { DeviceQueryDto } from '../dto/device-query.dto.js';
|
||||
import { UpdateImplantCatalogDto } from '../dto/update-implant-catalog.dto.js';
|
||||
import { UpdateDeviceDto } from '../dto/update-device.dto.js';
|
||||
import { DevicesService } from '../devices.service.js';
|
||||
|
||||
@ -40,73 +37,6 @@ import { DevicesService } from '../devices.service.js';
|
||||
export class BDevicesController {
|
||||
constructor(private readonly devicesService: DevicesService) {}
|
||||
|
||||
/**
|
||||
* 查询可见植入物型号字典。
|
||||
*/
|
||||
@Get('catalogs')
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
Role.ENGINEER,
|
||||
)
|
||||
@ApiOperation({ summary: '查询植入物型号字典' })
|
||||
@ApiQuery({
|
||||
name: 'keyword',
|
||||
required: false,
|
||||
description: '支持按型号、厂家、名称模糊查询',
|
||||
})
|
||||
findCatalogs(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Query('keyword') keyword?: string,
|
||||
) {
|
||||
return this.devicesService.findCatalogs(actor, keyword);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增植入物型号字典。
|
||||
*/
|
||||
@Post('catalogs')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '新增植入物目录(SYSTEM_ADMIN)' })
|
||||
createCatalog(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Body() dto: CreateImplantCatalogDto,
|
||||
) {
|
||||
return this.devicesService.createCatalog(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新植入物型号字典。
|
||||
*/
|
||||
@Patch('catalogs/:id')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '更新植入物目录(SYSTEM_ADMIN)' })
|
||||
@ApiParam({ name: 'id', description: '型号字典 ID' })
|
||||
updateCatalog(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateImplantCatalogDto,
|
||||
) {
|
||||
return this.devicesService.updateCatalog(actor, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除植入物目录。
|
||||
*/
|
||||
@Delete('catalogs/:id')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '删除植入物目录(SYSTEM_ADMIN)' })
|
||||
@ApiParam({ name: 'id', description: '型号字典 ID' })
|
||||
removeCatalog(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.devicesService.removeCatalog(actor, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备列表。
|
||||
*/
|
||||
|
||||
@ -9,35 +9,16 @@ 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';
|
||||
import { DeviceQueryDto } from './dto/device-query.dto.js';
|
||||
import { UpdateImplantCatalogDto } from './dto/update-implant-catalog.dto.js';
|
||||
import { UpdateDeviceDto } from './dto/update-device.dto.js';
|
||||
|
||||
const CATALOG_SELECT = {
|
||||
id: true,
|
||||
modelCode: true,
|
||||
manufacturer: true,
|
||||
name: true,
|
||||
pressureLevels: true,
|
||||
isPressureAdjustable: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
|
||||
const DEVICE_DETAIL_INCLUDE = {
|
||||
patient: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
inpatientNo: true,
|
||||
phone: true,
|
||||
hospitalId: true,
|
||||
hospital: {
|
||||
@ -55,17 +36,6 @@ const DEVICE_DETAIL_INCLUDE = {
|
||||
},
|
||||
},
|
||||
},
|
||||
surgery: {
|
||||
select: {
|
||||
id: true,
|
||||
surgeryDate: true,
|
||||
surgeryName: true,
|
||||
surgeonName: true,
|
||||
},
|
||||
},
|
||||
implantCatalog: {
|
||||
select: CATALOG_SELECT,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
taskItems: true,
|
||||
@ -74,7 +44,7 @@ const DEVICE_DETAIL_INCLUDE = {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 设备服务:承载患者植入实例 CRUD 与全局植入物目录维护。
|
||||
* 设备服务:承载管理员设备 CRUD、租户隔离与分页筛选。
|
||||
*/
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
@ -137,12 +107,14 @@ 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: {
|
||||
// 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。
|
||||
currentPressure: '0',
|
||||
snCode,
|
||||
currentPressure: this.normalizePressure(dto.currentPressure),
|
||||
status: dto.status ?? DeviceStatus.ACTIVE,
|
||||
patientId: patient.id,
|
||||
},
|
||||
@ -151,12 +123,20 @@ 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.currentPressure !== undefined) {
|
||||
data.currentPressure = this.normalizePressure(dto.currentPressure);
|
||||
}
|
||||
if (dto.status !== undefined) {
|
||||
data.status = this.normalizeStatus(dto.status);
|
||||
}
|
||||
@ -194,142 +174,6 @@ export class DevicesService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前角色可见的植入物型号字典。
|
||||
*/
|
||||
async findCatalogs(actor: ActorContext, keyword?: string) {
|
||||
this.assertCatalogReadable(actor);
|
||||
|
||||
const where = this.buildCatalogWhere(keyword);
|
||||
|
||||
return this.prisma.implantCatalog.findMany({
|
||||
where,
|
||||
select: CATALOG_SELECT,
|
||||
orderBy: [{ manufacturer: 'asc' }, { name: 'asc' }, { modelCode: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增植入物型号字典。
|
||||
*/
|
||||
async createCatalog(actor: ActorContext, dto: CreateImplantCatalogDto) {
|
||||
this.assertSystemAdmin(actor);
|
||||
const isPressureAdjustable = dto.isPressureAdjustable ?? true;
|
||||
|
||||
try {
|
||||
return await this.prisma.implantCatalog.create({
|
||||
data: {
|
||||
modelCode: this.normalizeModelCode(dto.modelCode),
|
||||
manufacturer: this.normalizeRequiredString(
|
||||
dto.manufacturer,
|
||||
'manufacturer',
|
||||
),
|
||||
name: this.normalizeRequiredString(dto.name, 'name'),
|
||||
pressureLevels: this.normalizePressureLevels(
|
||||
dto.pressureLevels,
|
||||
isPressureAdjustable,
|
||||
),
|
||||
isPressureAdjustable,
|
||||
notes:
|
||||
dto.notes === undefined
|
||||
? undefined
|
||||
: this.normalizeNullableString(dto.notes, 'notes'),
|
||||
},
|
||||
select: CATALOG_SELECT,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新植入物型号字典。
|
||||
*/
|
||||
async updateCatalog(
|
||||
actor: ActorContext,
|
||||
id: number,
|
||||
dto: UpdateImplantCatalogDto,
|
||||
) {
|
||||
this.assertSystemAdmin(actor);
|
||||
const current = await this.findWritableCatalog(id);
|
||||
const nextIsPressureAdjustable =
|
||||
dto.isPressureAdjustable ?? current.isPressureAdjustable;
|
||||
|
||||
const data: Prisma.ImplantCatalogUpdateInput = {};
|
||||
if (dto.modelCode !== undefined) {
|
||||
data.modelCode = this.normalizeModelCode(dto.modelCode);
|
||||
}
|
||||
if (dto.manufacturer !== undefined) {
|
||||
data.manufacturer = this.normalizeRequiredString(
|
||||
dto.manufacturer,
|
||||
'manufacturer',
|
||||
);
|
||||
}
|
||||
if (dto.name !== undefined) {
|
||||
data.name = this.normalizeRequiredString(dto.name, 'name');
|
||||
}
|
||||
if (dto.isPressureAdjustable !== undefined) {
|
||||
data.isPressureAdjustable = dto.isPressureAdjustable;
|
||||
}
|
||||
if (
|
||||
dto.pressureLevels !== undefined ||
|
||||
dto.isPressureAdjustable !== undefined
|
||||
) {
|
||||
data.pressureLevels = this.normalizePressureLevels(
|
||||
dto.pressureLevels ?? current.pressureLevels,
|
||||
nextIsPressureAdjustable,
|
||||
);
|
||||
}
|
||||
if (dto.notes !== undefined) {
|
||||
data.notes = this.normalizeNullableString(dto.notes, 'notes');
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.implantCatalog.update({
|
||||
where: { id: current.id },
|
||||
data,
|
||||
select: CATALOG_SELECT,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除植入物目录:若已被患者手术引用,则返回 409。
|
||||
*/
|
||||
async removeCatalog(actor: ActorContext, id: number) {
|
||||
this.assertSystemAdmin(actor);
|
||||
const current = await this.findWritableCatalog(id);
|
||||
|
||||
try {
|
||||
return await this.prisma.implantCatalog.delete({
|
||||
where: { id: current.id },
|
||||
select: CATALOG_SELECT,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
(error.code === 'P2003' || error.code === 'P2014')
|
||||
) {
|
||||
throw new ConflictException(MESSAGES.DEVICE.CATALOG_DELETE_CONFLICT);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造列表筛选:支持按医院、患者、状态和关键词组合查询。
|
||||
*/
|
||||
@ -363,13 +207,7 @@ export class DevicesService {
|
||||
andConditions.push({
|
||||
OR: [
|
||||
{
|
||||
implantModel: {
|
||||
contains: keyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
implantName: {
|
||||
snCode: {
|
||||
contains: keyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
@ -400,47 +238,6 @@ export class DevicesService {
|
||||
return andConditions.length > 0 ? { AND: andConditions } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造型号字典查询条件。
|
||||
*/
|
||||
private buildCatalogWhere(keyword?: string): Prisma.ImplantCatalogWhereInput {
|
||||
const andConditions: Prisma.ImplantCatalogWhereInput[] = [];
|
||||
const normalizedKeyword = keyword?.trim();
|
||||
|
||||
if (normalizedKeyword) {
|
||||
andConditions.push({
|
||||
OR: [
|
||||
{
|
||||
modelCode: {
|
||||
contains: normalizedKeyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
manufacturer: {
|
||||
contains: normalizedKeyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: {
|
||||
contains: normalizedKeyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
notes: {
|
||||
contains: normalizedKeyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return andConditions.length > 0 ? { AND: andConditions } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析列表分页。
|
||||
*/
|
||||
@ -504,23 +301,6 @@ export class DevicesService {
|
||||
return patient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前管理员可写的型号字典。
|
||||
*/
|
||||
private async findWritableCatalog(id: number) {
|
||||
const catalogId = this.toInt(id, 'id');
|
||||
const catalog = await this.prisma.implantCatalog.findUnique({
|
||||
where: { id: catalogId },
|
||||
select: CATALOG_SELECT,
|
||||
});
|
||||
|
||||
if (!catalog) {
|
||||
throw new NotFoundException(MESSAGES.DEVICE.CATALOG_NOT_FOUND);
|
||||
}
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前用户是否可读/写该设备。
|
||||
*/
|
||||
@ -535,7 +315,7 @@ export class DevicesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员角色校验:仅系统管理员与院管可操作患者植入实例。
|
||||
* 管理员角色校验:仅系统管理员与院管可操作设备。
|
||||
*/
|
||||
private assertAdmin(actor: ActorContext) {
|
||||
if (
|
||||
@ -547,83 +327,29 @@ export class DevicesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 型号字典读权限:B 端全部已登录角色可访问。
|
||||
* 设备 SN 标准化:统一去空白并转大写,避免大小写重复。
|
||||
*/
|
||||
private assertCatalogReadable(actor: ActorContext) {
|
||||
if (
|
||||
actor.role === Role.SYSTEM_ADMIN ||
|
||||
actor.role === Role.HOSPITAL_ADMIN ||
|
||||
actor.role === Role.DIRECTOR ||
|
||||
actor.role === Role.LEADER ||
|
||||
actor.role === Role.DOCTOR ||
|
||||
actor.role === Role.ENGINEER
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局植入物目录仅系统管理员可维护。
|
||||
*/
|
||||
private assertSystemAdmin(actor: ActorContext) {
|
||||
if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 型号编码标准化:统一去空白并转大写。
|
||||
*/
|
||||
private normalizeModelCode(value: unknown) {
|
||||
return this.normalizeRequiredString(value, 'modelCode').toUpperCase();
|
||||
}
|
||||
|
||||
private normalizeRequiredString(value: unknown, fieldName: string) {
|
||||
private normalizeSnCode(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(`${fieldName} 必须是字符串`);
|
||||
throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED);
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (!normalized) {
|
||||
throw new BadRequestException(`${fieldName} 不能为空`);
|
||||
throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeNullableString(value: unknown, fieldName: string) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(`${fieldName} 必须是字符串`);
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 挡位列表标准化:去重、排序,并在非可调压目录下自动清空。
|
||||
*/
|
||||
private normalizePressureLevels(
|
||||
pressureLevels: unknown[] | undefined,
|
||||
isPressureAdjustable: boolean,
|
||||
) {
|
||||
if (!isPressureAdjustable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return normalizePressureLabelList(pressureLevels, 'pressureLevels');
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前压力挡位标签标准化。
|
||||
* 压力值必须是非负整数。
|
||||
*/
|
||||
private normalizePressure(value: unknown) {
|
||||
try {
|
||||
return normalizePressureLabel(value, 'currentPressure');
|
||||
} catch {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -660,4 +386,18 @@ 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,12 +1,22 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { DeviceStatus } from '../../generated/prisma/enums.js';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, Min } from 'class-validator';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 创建设备 DTO。
|
||||
*/
|
||||
export class CreateDeviceDto {
|
||||
@ApiProperty({ description: '设备 SN', example: 'TYT-SN-10001' })
|
||||
@IsString({ message: 'snCode 必须是字符串' })
|
||||
snCode!: string;
|
||||
|
||||
@ApiProperty({ description: '当前压力值', example: 120 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'currentPressure 必须是整数' })
|
||||
@Min(0, { message: 'currentPressure 必须大于等于 0' })
|
||||
currentPressure!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '设备状态,默认 ACTIVE',
|
||||
enum: DeviceStatus,
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
|
||||
|
||||
/**
|
||||
* 植入物目录创建 DTO。
|
||||
*/
|
||||
export class CreateImplantCatalogDto {
|
||||
@ApiProperty({
|
||||
description: '型号编码',
|
||||
example: 'CODMAN-HAKIM-120',
|
||||
})
|
||||
@IsString({ message: 'modelCode 必须是字符串' })
|
||||
modelCode!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '厂家',
|
||||
example: 'Codman',
|
||||
})
|
||||
@IsString({ message: 'manufacturer 必须是字符串' })
|
||||
manufacturer!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '名称',
|
||||
example: 'Hakim 可调压阀',
|
||||
})
|
||||
@IsString({ message: 'name 必须是字符串' })
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '可调压器械的挡位列表,按字符串挡位标签录入',
|
||||
type: [String],
|
||||
example: ['0.5', '1', '1.5'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'pressureLevels 必须是数组' })
|
||||
@ArrayMaxSize(30, { message: 'pressureLevels 最多 30 项' })
|
||||
@IsString({ each: true, message: 'pressureLevels 必须为字符串数组' })
|
||||
pressureLevels?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '是否支持调压,默认 true',
|
||||
example: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@ToBoolean()
|
||||
@IsBoolean({ message: 'isPressureAdjustable 必须是布尔值' })
|
||||
isPressureAdjustable?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '植入物备注',
|
||||
example: '适用于儿童脑积水病例',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'notes 必须是字符串' })
|
||||
@MaxLength(200, { message: 'notes 最长 200 个字符' })
|
||||
notes?: string;
|
||||
}
|
||||
@ -9,9 +9,8 @@ import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
*/
|
||||
export class DeviceQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'关键词(支持植入物型号 / 植入物名称 / 患者姓名 / 患者手机号)',
|
||||
example: '脑室',
|
||||
description: '关键词(支持设备 SN / 患者姓名 / 患者手机号)',
|
||||
example: 'SN-A',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'keyword 必须是字符串' })
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateImplantCatalogDto } from './create-implant-catalog.dto.js';
|
||||
|
||||
/**
|
||||
* 植入物型号更新 DTO。
|
||||
*/
|
||||
export class UpdateImplantCatalogDto extends PartialType(
|
||||
CreateImplantCatalogDto,
|
||||
) {}
|
||||
@ -1,112 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||
import { Roles } from '../../auth/roles.decorator.js';
|
||||
import { RolesGuard } from '../../auth/roles.guard.js';
|
||||
import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { CreateDictionaryItemDto } from '../dto/create-dictionary-item.dto.js';
|
||||
import { DictionaryQueryDto } from '../dto/dictionary-query.dto.js';
|
||||
import { UpdateDictionaryItemDto } from '../dto/update-dictionary-item.dto.js';
|
||||
import { DictionariesService } from '../dictionaries.service.js';
|
||||
|
||||
/**
|
||||
* B 端字典控制器:供患者表单读取和系统管理员维护选项字典。
|
||||
*/
|
||||
@ApiTags('字典管理(B端)')
|
||||
@ApiBearerAuth('bearer')
|
||||
@Controller('b/dictionaries')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class BDictionariesController {
|
||||
constructor(private readonly dictionariesService: DictionariesService) {}
|
||||
|
||||
/**
|
||||
* 查询字典项列表。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
Role.ENGINEER,
|
||||
)
|
||||
@ApiOperation({ summary: '查询系统字典' })
|
||||
@ApiQuery({
|
||||
name: 'type',
|
||||
required: false,
|
||||
description: '字典类型,不传返回全部类型',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'includeDisabled',
|
||||
required: false,
|
||||
description: '是否包含停用项,仅系统管理员生效',
|
||||
})
|
||||
findAll(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Query() query: DictionaryQueryDto,
|
||||
) {
|
||||
return this.dictionariesService.findAll(actor, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建字典项。
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '创建系统字典项(SYSTEM_ADMIN)' })
|
||||
create(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Body() dto: CreateDictionaryItemDto,
|
||||
) {
|
||||
return this.dictionariesService.create(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新字典项。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '更新系统字典项(SYSTEM_ADMIN)' })
|
||||
@ApiParam({ name: 'id', description: '字典项 ID' })
|
||||
update(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateDictionaryItemDto,
|
||||
) {
|
||||
return this.dictionariesService.update(actor, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除字典项。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '删除系统字典项(SYSTEM_ADMIN)' })
|
||||
@ApiParam({ name: 'id', description: '字典项 ID' })
|
||||
remove(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.dictionariesService.remove(actor, id);
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
import { BDictionariesController } from './b-dictionaries/b-dictionaries.controller.js';
|
||||
import { DictionariesService } from './dictionaries.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [BDictionariesController],
|
||||
providers: [DictionariesService, AccessTokenGuard, RolesGuard],
|
||||
exports: [DictionariesService],
|
||||
})
|
||||
export class DictionariesModule {}
|
||||
@ -1,156 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '../generated/prisma/client.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
import { MESSAGES } from '../common/messages.js';
|
||||
import { PrismaService } from '../prisma.service.js';
|
||||
import { CreateDictionaryItemDto } from './dto/create-dictionary-item.dto.js';
|
||||
import { DictionaryQueryDto } from './dto/dictionary-query.dto.js';
|
||||
import { UpdateDictionaryItemDto } from './dto/update-dictionary-item.dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class DictionariesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 查询字典项列表。
|
||||
*/
|
||||
async findAll(actor: ActorContext, query: DictionaryQueryDto) {
|
||||
const where: Prisma.DictionaryItemWhereInput = {};
|
||||
|
||||
if (query.type) {
|
||||
where.type = query.type;
|
||||
}
|
||||
|
||||
// 非系统管理员一律只看启用项,避免业务页面误拿到停用值。
|
||||
if (actor.role !== Role.SYSTEM_ADMIN || !query.includeDisabled) {
|
||||
where.enabled = true;
|
||||
}
|
||||
|
||||
return this.prisma.dictionaryItem.findMany({
|
||||
where,
|
||||
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建字典项:仅系统管理员可维护。
|
||||
*/
|
||||
async create(actor: ActorContext, dto: CreateDictionaryItemDto) {
|
||||
this.assertSystemAdmin(actor);
|
||||
|
||||
try {
|
||||
return await this.prisma.dictionaryItem.create({
|
||||
data: {
|
||||
type: dto.type,
|
||||
label: this.normalizeLabel(dto.label),
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
enabled: dto.enabled ?? true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.handleDuplicate(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新字典项:支持调整分类、排序和启停状态。
|
||||
*/
|
||||
async update(actor: ActorContext, id: number, dto: UpdateDictionaryItemDto) {
|
||||
this.assertSystemAdmin(actor);
|
||||
await this.ensureExists(id);
|
||||
|
||||
const data: Prisma.DictionaryItemUpdateInput = {};
|
||||
if (dto.type !== undefined) {
|
||||
data.type = dto.type;
|
||||
}
|
||||
if (dto.label !== undefined) {
|
||||
data.label = this.normalizeLabel(dto.label);
|
||||
}
|
||||
if (dto.sortOrder !== undefined) {
|
||||
data.sortOrder = dto.sortOrder;
|
||||
}
|
||||
if (dto.enabled !== undefined) {
|
||||
data.enabled = dto.enabled;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.dictionaryItem.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
this.handleDuplicate(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除字典项。
|
||||
*/
|
||||
async remove(actor: ActorContext, id: number) {
|
||||
this.assertSystemAdmin(actor);
|
||||
await this.ensureExists(id);
|
||||
|
||||
return this.prisma.dictionaryItem.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化字典名称并确保非空。
|
||||
*/
|
||||
private normalizeLabel(value: string) {
|
||||
const label = value?.trim();
|
||||
if (!label) {
|
||||
throw new BadRequestException(MESSAGES.DICTIONARY.LABEL_REQUIRED);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验系统管理员权限。
|
||||
*/
|
||||
private assertSystemAdmin(actor: ActorContext) {
|
||||
if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||
throw new ForbiddenException(
|
||||
MESSAGES.DICTIONARY.SYSTEM_ADMIN_ONLY_MAINTAIN,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认字典项存在。
|
||||
*/
|
||||
private async ensureExists(id: number) {
|
||||
const current = await this.prisma.dictionaryItem.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
throw new NotFoundException(MESSAGES.DICTIONARY.NOT_FOUND);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一处理唯一键冲突。
|
||||
*/
|
||||
private handleDuplicate(error: unknown) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException(MESSAGES.DICTIONARY.DUPLICATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
|
||||
import { DictionaryType } from '../../generated/prisma/enums.js';
|
||||
|
||||
/**
|
||||
* 系统字典项创建 DTO。
|
||||
*/
|
||||
export class CreateDictionaryItemDto {
|
||||
@ApiProperty({
|
||||
description: '字典类型',
|
||||
enum: DictionaryType,
|
||||
example: DictionaryType.PRIMARY_DISEASE,
|
||||
})
|
||||
@IsEnum(DictionaryType, { message: 'type 枚举值不合法' })
|
||||
type!: DictionaryType;
|
||||
|
||||
@ApiProperty({
|
||||
description: '字典项名称',
|
||||
example: '先天性脑积水',
|
||||
})
|
||||
@IsString({ message: 'label 必须是字符串' })
|
||||
label!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '排序值,越小越靠前,默认 0',
|
||||
example: 10,
|
||||
default: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'sortOrder 必须是整数' })
|
||||
@Min(-9999, { message: 'sortOrder 不能小于 -9999' })
|
||||
@Max(9999, { message: 'sortOrder 不能大于 9999' })
|
||||
sortOrder?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '是否启用,默认 true',
|
||||
example: true,
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@ToBoolean()
|
||||
@IsBoolean({ message: 'enabled 必须是布尔值' })
|
||||
enabled?: boolean;
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsEnum, IsOptional } from 'class-validator';
|
||||
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
|
||||
import { DictionaryType } from '../../generated/prisma/enums.js';
|
||||
|
||||
/**
|
||||
* 字典查询 DTO:支持按类型筛选,并允许系统管理员查看停用项。
|
||||
*/
|
||||
export class DictionaryQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '字典类型,不传返回全部类型',
|
||||
enum: DictionaryType,
|
||||
example: DictionaryType.PRIMARY_DISEASE,
|
||||
})
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@IsEnum(DictionaryType, { message: 'type 枚举值不合法' })
|
||||
type?: DictionaryType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '是否包含停用项,仅系统管理员生效',
|
||||
example: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@ToBoolean()
|
||||
@IsBoolean({ message: 'includeDisabled 必须是布尔值' })
|
||||
includeDisabled?: boolean;
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateDictionaryItemDto } from './create-dictionary-item.dto.js';
|
||||
|
||||
/**
|
||||
* 系统字典项更新 DTO。
|
||||
*/
|
||||
export class UpdateDictionaryItemDto extends PartialType(
|
||||
CreateDictionaryItemDto,
|
||||
) {}
|
||||
@ -41,9 +41,12 @@ export class GroupsController {
|
||||
* 创建小组。
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@ApiOperation({ summary: '创建小组' })
|
||||
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateGroupDto) {
|
||||
create(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Body() dto: CreateGroupDto,
|
||||
) {
|
||||
return this.groupsService.create(actor, dto);
|
||||
}
|
||||
|
||||
@ -56,7 +59,6 @@ export class GroupsController {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '查询小组列表' })
|
||||
findAll(
|
||||
@ -75,7 +77,6 @@ export class GroupsController {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '查询小组详情' })
|
||||
@ApiParam({ name: 'id', description: '小组 ID' })
|
||||
@ -90,7 +91,12 @@ export class GroupsController {
|
||||
* 更新小组。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '更新小组' })
|
||||
update(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@ -104,7 +110,7 @@ export class GroupsController {
|
||||
* 删除小组。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@ApiOperation({ summary: '删除小组' })
|
||||
remove(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
|
||||
@ -10,12 +10,7 @@ import { OrganizationAccessService } from '../organization-common/organization-a
|
||||
*/
|
||||
@Module({
|
||||
controllers: [GroupsController],
|
||||
providers: [
|
||||
GroupsService,
|
||||
OrganizationAccessService,
|
||||
AccessTokenGuard,
|
||||
RolesGuard,
|
||||
],
|
||||
providers: [GroupsService, OrganizationAccessService, AccessTokenGuard, RolesGuard],
|
||||
exports: [GroupsService],
|
||||
})
|
||||
export class GroupsModule {}
|
||||
|
||||
@ -26,10 +26,14 @@ export class GroupsService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建小组:系统管理员可跨院;院管仅可在本院。
|
||||
* 创建小组:系统管理员可跨院;院管仅可在本院;主任仅可在本科室创建。
|
||||
*/
|
||||
async create(actor: ActorContext, dto: CreateGroupDto) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
]);
|
||||
const departmentId = this.access.toInt(
|
||||
dto.departmentId,
|
||||
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
|
||||
@ -38,6 +42,12 @@ 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: {
|
||||
@ -51,7 +61,7 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询小组列表:院管限定本院。
|
||||
* 查询小组列表:院管限定本院;主任限定本科室;组长限定本组。
|
||||
*/
|
||||
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
||||
this.access.assertRole(actor, [
|
||||
@ -59,7 +69,6 @@ export class GroupsService {
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
]);
|
||||
const paging = this.access.resolvePaging(query);
|
||||
const where: Prisma.GroupWhereInput = {};
|
||||
@ -78,12 +87,10 @@ export class GroupsService {
|
||||
where.department = {
|
||||
hospitalId: this.access.requireActorHospitalId(actor),
|
||||
};
|
||||
} else if (
|
||||
actor.role === Role.DIRECTOR ||
|
||||
actor.role === Role.LEADER ||
|
||||
actor.role === Role.DOCTOR
|
||||
) {
|
||||
} else if (actor.role === Role.DIRECTOR) {
|
||||
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(
|
||||
@ -111,7 +118,7 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询小组详情:院管仅可查看本院。
|
||||
* 查询小组详情:院管仅可查看本院;主任仅可查看本科室;组长仅可查看本组。
|
||||
*/
|
||||
async findOne(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [
|
||||
@ -119,7 +126,6 @@ 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({
|
||||
@ -134,21 +140,24 @@ export class GroupsService {
|
||||
}
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
this.access.assertHospitalScope(actor, group.department.hospital.id);
|
||||
} else if (
|
||||
actor.role === Role.DIRECTOR ||
|
||||
actor.role === Role.LEADER ||
|
||||
actor.role === Role.DOCTOR
|
||||
) {
|
||||
}
|
||||
if (actor.role === Role.DIRECTOR) {
|
||||
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
||||
if (group.department.id !== actorDepartmentId) {
|
||||
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
|
||||
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);
|
||||
}
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新小组:院管仅可修改本院。
|
||||
* 更新小组:院管仅可修改本院;主任仅可修改本科室;组长仅可修改本组。
|
||||
*/
|
||||
async update(actor: ActorContext, id: number, dto: UpdateGroupDto) {
|
||||
const current = await this.findOne(actor, id);
|
||||
@ -172,10 +181,14 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除小组:院管仅可删除本院。
|
||||
* 删除小组:院管仅可删除本院;主任仅可删除本科室小组。
|
||||
*/
|
||||
async remove(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
]);
|
||||
const current = await this.findOne(actor, id);
|
||||
|
||||
// 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。
|
||||
|
||||
@ -43,7 +43,10 @@ export class HospitalsController {
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '创建医院(SYSTEM_ADMIN)' })
|
||||
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateHospitalDto) {
|
||||
create(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Body() dto: CreateHospitalDto,
|
||||
) {
|
||||
return this.hospitalsService.create(actor, dto);
|
||||
}
|
||||
|
||||
@ -51,7 +54,12 @@ export class HospitalsController {
|
||||
* 查询医院列表(系统管理员全量,院管仅本院)。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '查询医院列表' })
|
||||
findAll(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@ -64,7 +72,12 @@ export class HospitalsController {
|
||||
* 查询医院详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '查询医院详情' })
|
||||
@ApiParam({ name: 'id', description: '医院 ID' })
|
||||
findOne(
|
||||
|
||||
@ -23,16 +23,10 @@ export class HospitalsService {
|
||||
* 创建医院:仅系统管理员可调用。
|
||||
*/
|
||||
async create(actor: ActorContext, dto: CreateHospitalDto) {
|
||||
this.access.assertSystemAdmin(
|
||||
actor,
|
||||
MESSAGES.ORG.SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL,
|
||||
);
|
||||
this.access.assertSystemAdmin(actor, MESSAGES.ORG.SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL);
|
||||
return this.prisma.hospital.create({
|
||||
data: {
|
||||
name: this.access.normalizeName(
|
||||
dto.name,
|
||||
MESSAGES.ORG.HOSPITAL_NAME_REQUIRED,
|
||||
),
|
||||
name: this.access.normalizeName(dto.name, MESSAGES.ORG.HOSPITAL_NAME_REQUIRED),
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -85,12 +79,7 @@ export class HospitalsService {
|
||||
where: { id: hospitalId },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
departments: true,
|
||||
users: true,
|
||||
patients: true,
|
||||
tasks: true,
|
||||
},
|
||||
select: { departments: true, users: true, patients: true, tasks: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -108,10 +97,7 @@ export class HospitalsService {
|
||||
const current = await this.findOne(actor, id);
|
||||
const data: Prisma.HospitalUpdateInput = {};
|
||||
if (dto.name !== undefined) {
|
||||
data.name = this.access.normalizeName(
|
||||
dto.name,
|
||||
MESSAGES.ORG.HOSPITAL_NAME_REQUIRED,
|
||||
);
|
||||
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.HOSPITAL_NAME_REQUIRED);
|
||||
}
|
||||
return this.prisma.hospital.update({
|
||||
where: { id: current.id },
|
||||
@ -123,10 +109,7 @@ export class HospitalsService {
|
||||
* 删除医院:仅系统管理员允许。
|
||||
*/
|
||||
async remove(actor: ActorContext, id: number) {
|
||||
this.access.assertSystemAdmin(
|
||||
actor,
|
||||
MESSAGES.ORG.SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL,
|
||||
);
|
||||
this.access.assertSystemAdmin(actor, MESSAGES.ORG.SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL);
|
||||
const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
await this.access.ensureHospitalExists(hospitalId);
|
||||
|
||||
|
||||
@ -1,23 +1,16 @@
|
||||
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<NestExpressApplication>(AppModule);
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors();
|
||||
mkdirSync(resolveUploadRootDir(), { recursive: true });
|
||||
app.useStaticAssets(resolveUploadRootDir(), {
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
|
||||
@ -7,10 +7,7 @@ import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
* 组织查询 DTO:用于医院/科室/小组列表筛选与分页。
|
||||
*/
|
||||
export class OrganizationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '关键词(按名称模糊匹配)',
|
||||
example: '神经',
|
||||
})
|
||||
@ApiPropertyOptional({ description: '关键词(按名称模糊匹配)', example: '神经' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'keyword 必须是字符串' })
|
||||
keyword?: string;
|
||||
@ -31,11 +28,7 @@ export class OrganizationQueryDto {
|
||||
@Min(1, { message: 'departmentId 必须大于 0' })
|
||||
departmentId?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '页码(默认 1)',
|
||||
example: 1,
|
||||
default: 1,
|
||||
})
|
||||
@ApiPropertyOptional({ description: '页码(默认 1)', example: 1, default: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
|
||||
@ -66,9 +66,9 @@ export class OrganizationAccessService {
|
||||
*/
|
||||
requireActorHospitalId(actor: ActorContext): number {
|
||||
if (
|
||||
typeof actor.hospitalId !== 'number' ||
|
||||
!Number.isInteger(actor.hospitalId) ||
|
||||
actor.hospitalId <= 0
|
||||
typeof actor.hospitalId !== 'number'
|
||||
|| !Number.isInteger(actor.hospitalId)
|
||||
|| actor.hospitalId <= 0
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED);
|
||||
}
|
||||
@ -80,9 +80,9 @@ export class OrganizationAccessService {
|
||||
*/
|
||||
requireActorDepartmentId(actor: ActorContext): number {
|
||||
if (
|
||||
typeof actor.departmentId !== 'number' ||
|
||||
!Number.isInteger(actor.departmentId) ||
|
||||
actor.departmentId <= 0
|
||||
typeof actor.departmentId !== 'number'
|
||||
|| !Number.isInteger(actor.departmentId)
|
||||
|| actor.departmentId <= 0
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED);
|
||||
}
|
||||
@ -94,9 +94,9 @@ export class OrganizationAccessService {
|
||||
*/
|
||||
requireActorGroupId(actor: ActorContext): number {
|
||||
if (
|
||||
typeof actor.groupId !== 'number' ||
|
||||
!Number.isInteger(actor.groupId) ||
|
||||
actor.groupId <= 0
|
||||
typeof actor.groupId !== 'number'
|
||||
|| !Number.isInteger(actor.groupId)
|
||||
|| actor.groupId <= 0
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.ORG.ACTOR_GROUP_REQUIRED);
|
||||
}
|
||||
|
||||
@ -25,7 +25,6 @@ import { Roles } from '../../auth/roles.decorator.js';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { BPatientsService } from './b-patients.service.js';
|
||||
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
||||
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
|
||||
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
||||
|
||||
/**
|
||||
@ -103,34 +102,10 @@ export class BPatientsController {
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '创建患者' })
|
||||
createPatient(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Body() dto: CreatePatientDto,
|
||||
) {
|
||||
createPatient(@CurrentActor() actor: ActorContext, @Body() dto: CreatePatientDto) {
|
||||
return this.patientsService.createPatient(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为患者新增手术记录。
|
||||
*/
|
||||
@Post(':id/surgeries')
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '为患者新增手术记录' })
|
||||
@ApiParam({ name: 'id', description: '患者 ID' })
|
||||
createPatientSurgery(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: CreatePatientSurgeryDto,
|
||||
) {
|
||||
return this.patientsService.createPatientSurgery(actor, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询患者详情。
|
||||
*/
|
||||
|
||||
@ -6,134 +6,18 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma/client.js';
|
||||
import { DeviceStatus, Role } from '../../generated/prisma/enums.js';
|
||||
import { 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';
|
||||
import { normalizePatientIdCard } from '../patient-id-card.util.js';
|
||||
|
||||
type PrismaExecutor = Prisma.TransactionClient | PrismaService;
|
||||
|
||||
const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER];
|
||||
|
||||
const IMPLANT_CATALOG_SELECT = {
|
||||
id: true,
|
||||
modelCode: true,
|
||||
manufacturer: true,
|
||||
name: true,
|
||||
pressureLevels: true,
|
||||
isPressureAdjustable: true,
|
||||
notes: true,
|
||||
} as const;
|
||||
|
||||
const PATIENT_LIST_INCLUDE = {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: { select: { id: true, name: true, role: true } },
|
||||
creator: { select: { id: true, name: true, role: true } },
|
||||
devices: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
currentPressure: true,
|
||||
isAbandoned: true,
|
||||
implantModel: true,
|
||||
implantManufacturer: true,
|
||||
implantName: true,
|
||||
isPressureAdjustable: true,
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
},
|
||||
surgeries: {
|
||||
select: {
|
||||
id: true,
|
||||
surgeryDate: true,
|
||||
surgeryName: true,
|
||||
surgeonId: true,
|
||||
surgeonName: true,
|
||||
},
|
||||
orderBy: { surgeryDate: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
surgeries: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PATIENT_DETAIL_INCLUDE = {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
role: true,
|
||||
hospitalId: true,
|
||||
departmentId: true,
|
||||
groupId: true,
|
||||
},
|
||||
},
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
include: {
|
||||
implantCatalog: {
|
||||
select: IMPLANT_CATALOG_SELECT,
|
||||
},
|
||||
surgery: {
|
||||
select: {
|
||||
id: true,
|
||||
surgeryDate: true,
|
||||
surgeryName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
},
|
||||
surgeries: {
|
||||
include: {
|
||||
devices: {
|
||||
include: {
|
||||
implantCatalog: {
|
||||
select: IMPLANT_CATALOG_SELECT,
|
||||
},
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
},
|
||||
surgeon: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { surgeryDate: 'desc' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PATIENT_SURGERY_DETAIL_INCLUDE = {
|
||||
devices: {
|
||||
include: {
|
||||
implantCatalog: {
|
||||
select: IMPLANT_CATALOG_SELECT,
|
||||
},
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* B 端患者服务:承载院内可见性隔离、患者 CRUD 与手术档案录入。
|
||||
* B 端患者服务:承载院内可见性隔离与患者 CRUD。
|
||||
*/
|
||||
@Injectable()
|
||||
export class BPatientsService {
|
||||
@ -146,27 +30,15 @@ export class BPatientsService {
|
||||
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
|
||||
const where = this.buildVisiblePatientWhere(actor, hospitalId);
|
||||
|
||||
const patients = await this.prisma.patient.findMany({
|
||||
return this.prisma.patient.findMany({
|
||||
where,
|
||||
include: PATIENT_LIST_INCLUDE,
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: { select: { id: true, name: true, role: true } },
|
||||
devices: true,
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
|
||||
return patients.map((patient) => {
|
||||
const { _count, surgeries, ...rest } = patient;
|
||||
return {
|
||||
...rest,
|
||||
shuntSurgeryCount: _count.surgeries,
|
||||
latestSurgery: surgeries[0] ?? null,
|
||||
activeDeviceCount: patient.devices.filter(
|
||||
(device) =>
|
||||
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
|
||||
).length,
|
||||
abandonedDeviceCount: patient.devices.filter(
|
||||
(device) => device.isAbandoned,
|
||||
).length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -218,78 +90,25 @@ export class BPatientsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建患者,可选一并创建首台手术及植入设备。
|
||||
* 创建患者。
|
||||
*/
|
||||
async createPatient(actor: ActorContext, dto: CreatePatientDto) {
|
||||
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const patient = await tx.patient.create({
|
||||
data: {
|
||||
name: this.normalizeRequiredString(dto.name, 'name'),
|
||||
creatorId: actor.id,
|
||||
inpatientNo:
|
||||
dto.inpatientNo === undefined
|
||||
? undefined
|
||||
: this.normalizeNullableString(dto.inpatientNo, 'inpatientNo'),
|
||||
projectName:
|
||||
dto.projectName === undefined
|
||||
? undefined
|
||||
: this.normalizeNullableString(dto.projectName, 'projectName'),
|
||||
phone: this.normalizePhone(dto.phone),
|
||||
// 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。
|
||||
idCard: this.normalizeIdCard(dto.idCard),
|
||||
hospitalId: doctor.hospitalId!,
|
||||
doctorId: doctor.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.initialSurgery) {
|
||||
await this.createPatientSurgeryRecord(
|
||||
tx,
|
||||
actor,
|
||||
patient.id,
|
||||
patient.doctorId,
|
||||
dto.initialSurgery,
|
||||
);
|
||||
}
|
||||
|
||||
const detail = await this.loadPatientDetail(tx, patient.id);
|
||||
return this.decoratePatientDetail(detail);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 为患者新增一台手术,并支持弃用旧设备。
|
||||
*/
|
||||
async createPatientSurgery(
|
||||
actor: ActorContext,
|
||||
patientId: number,
|
||||
dto: CreatePatientSurgeryDto,
|
||||
) {
|
||||
const patient = await this.findPatientWithScope(patientId);
|
||||
this.assertPatientScope(actor, patient);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const createdSurgery = await this.createPatientSurgeryRecord(
|
||||
tx,
|
||||
actor,
|
||||
patient.id,
|
||||
patient.doctorId,
|
||||
dto,
|
||||
);
|
||||
|
||||
const detail = await this.loadPatientDetail(tx, patient.id);
|
||||
const decoratedPatient = this.decoratePatientDetail(detail);
|
||||
const created = decoratedPatient.surgeries.find(
|
||||
(surgery) => surgery.id === createdSurgery.id,
|
||||
);
|
||||
|
||||
if (!created) {
|
||||
throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND);
|
||||
}
|
||||
|
||||
return created;
|
||||
return this.prisma.patient.create({
|
||||
data: {
|
||||
name: this.normalizeRequiredString(dto.name, 'name'),
|
||||
phone: this.normalizePhone(dto.phone),
|
||||
// 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。
|
||||
idCard: this.normalizeIdCard(dto.idCard),
|
||||
hospitalId: doctor.hospitalId!,
|
||||
doctorId: doctor.id,
|
||||
},
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: { select: { id: true, name: true, role: true } },
|
||||
devices: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -299,38 +118,20 @@ export class BPatientsService {
|
||||
async findPatientById(actor: ActorContext, id: number) {
|
||||
const patient = await this.findPatientWithScope(id);
|
||||
this.assertPatientScope(actor, patient);
|
||||
return this.decoratePatientDetail(patient);
|
||||
return patient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新患者基础信息。
|
||||
* 更新患者信息。
|
||||
*/
|
||||
async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) {
|
||||
const patient = await this.findPatientWithScope(id);
|
||||
this.assertPatientScope(actor, patient);
|
||||
|
||||
if (dto.initialSurgery !== undefined) {
|
||||
throw new BadRequestException(
|
||||
MESSAGES.PATIENT.SURGERY_UPDATE_NOT_SUPPORTED,
|
||||
);
|
||||
}
|
||||
|
||||
const data: Prisma.PatientUpdateInput = {};
|
||||
if (dto.name !== undefined) {
|
||||
data.name = this.normalizeRequiredString(dto.name, 'name');
|
||||
}
|
||||
if (dto.inpatientNo !== undefined) {
|
||||
data.inpatientNo = this.normalizeNullableString(
|
||||
dto.inpatientNo,
|
||||
'inpatientNo',
|
||||
);
|
||||
}
|
||||
if (dto.projectName !== undefined) {
|
||||
data.projectName = this.normalizeNullableString(
|
||||
dto.projectName,
|
||||
'projectName',
|
||||
);
|
||||
}
|
||||
if (dto.phone !== undefined) {
|
||||
data.phone = this.normalizePhone(dto.phone);
|
||||
}
|
||||
@ -344,13 +145,15 @@ export class BPatientsService {
|
||||
data.hospital = { connect: { id: doctor.hospitalId! } };
|
||||
}
|
||||
|
||||
const updated = await this.prisma.patient.update({
|
||||
return this.prisma.patient.update({
|
||||
where: { id: patient.id },
|
||||
data,
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: { select: { id: true, name: true, role: true } },
|
||||
devices: true,
|
||||
},
|
||||
});
|
||||
|
||||
const detail = await this.loadPatientDetail(this.prisma, updated.id);
|
||||
return this.decoratePatientDetail(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -361,11 +164,14 @@ export class BPatientsService {
|
||||
this.assertPatientScope(actor, patient);
|
||||
|
||||
try {
|
||||
const deleted = await this.prisma.patient.delete({
|
||||
return await this.prisma.patient.delete({
|
||||
where: { id: patient.id },
|
||||
include: PATIENT_DETAIL_INCLUDE,
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: { select: { id: true, name: true, role: true } },
|
||||
devices: true,
|
||||
},
|
||||
});
|
||||
return this.decoratePatientDetail(deleted);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
@ -386,16 +192,22 @@ export class BPatientsService {
|
||||
throw new BadRequestException('id 必须为整数');
|
||||
}
|
||||
|
||||
return this.loadPatientDetail(this.prisma, patientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一加载患者详情。
|
||||
*/
|
||||
private async loadPatientDetail(prisma: PrismaExecutor, patientId: number) {
|
||||
const patient = await prisma.patient.findUnique({
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id: patientId },
|
||||
include: PATIENT_DETAIL_INCLUDE,
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
role: true,
|
||||
hospitalId: true,
|
||||
departmentId: true,
|
||||
groupId: true,
|
||||
},
|
||||
},
|
||||
devices: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!patient) {
|
||||
@ -460,7 +272,6 @@ export class BPatientsService {
|
||||
where: { id: normalizedDoctorId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
role: true,
|
||||
hospitalId: true,
|
||||
departmentId: true,
|
||||
@ -569,299 +380,6 @@ export class BPatientsService {
|
||||
return actor.hospitalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建患者手术记录并写入设备快照。
|
||||
*/
|
||||
private async createPatientSurgeryRecord(
|
||||
prisma: PrismaExecutor,
|
||||
actor: ActorContext,
|
||||
patientId: number,
|
||||
patientDoctorId: number,
|
||||
dto: CreatePatientSurgeryDto,
|
||||
) {
|
||||
if (!Array.isArray(dto.devices) || dto.devices.length === 0) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.SURGERY_ITEMS_REQUIRED);
|
||||
}
|
||||
|
||||
const catalogIds = Array.from(
|
||||
new Set(
|
||||
dto.devices.map((device) =>
|
||||
this.toInt(device.implantCatalogId, 'implantCatalogId'),
|
||||
),
|
||||
),
|
||||
);
|
||||
const abandonedDeviceIds = Array.from(
|
||||
new Set(dto.abandonedDeviceIds ?? []),
|
||||
);
|
||||
|
||||
const [catalogMap, latestSurgery, 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) {
|
||||
const devices = await prisma.device.findMany({
|
||||
where: {
|
||||
id: { in: abandonedDeviceIds },
|
||||
patientId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (devices.length !== abandonedDeviceIds.length) {
|
||||
throw new ForbiddenException(
|
||||
MESSAGES.PATIENT.ABANDON_DEVICE_SCOPE_FORBIDDEN,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const deviceDrafts = dto.devices.map((device) => {
|
||||
const catalog = catalogMap.get(device.implantCatalogId);
|
||||
if (!catalog) {
|
||||
throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND);
|
||||
}
|
||||
|
||||
const initialPressure =
|
||||
device.initialPressure == null
|
||||
? null
|
||||
: this.assertPressureLevelAllowed(
|
||||
catalog,
|
||||
this.normalizePressureLevel(
|
||||
device.initialPressure,
|
||||
'initialPressure',
|
||||
),
|
||||
);
|
||||
const fallbackPressureLevel =
|
||||
catalog.isPressureAdjustable && catalog.pressureLevels.length > 0
|
||||
? catalog.pressureLevels[0]
|
||||
: '0';
|
||||
const currentPressure = catalog.isPressureAdjustable
|
||||
? this.assertPressureLevelAllowed(
|
||||
catalog,
|
||||
initialPressure ?? fallbackPressureLevel,
|
||||
)
|
||||
: '0';
|
||||
|
||||
return {
|
||||
patient: { connect: { id: patientId } },
|
||||
implantCatalog: { connect: { id: catalog.id } },
|
||||
currentPressure,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
implantModel: catalog.modelCode,
|
||||
implantManufacturer: catalog.manufacturer,
|
||||
implantName: catalog.name,
|
||||
isPressureAdjustable: catalog.isPressureAdjustable,
|
||||
isAbandoned: false,
|
||||
shuntMode: this.normalizeRequiredString(device.shuntMode, 'shuntMode'),
|
||||
proximalPunctureAreas: this.normalizeStringArray(
|
||||
device.proximalPunctureAreas,
|
||||
'proximalPunctureAreas',
|
||||
),
|
||||
valvePlacementSites: this.normalizeStringArray(
|
||||
device.valvePlacementSites,
|
||||
'valvePlacementSites',
|
||||
),
|
||||
distalShuntDirection: this.normalizeRequiredString(
|
||||
device.distalShuntDirection,
|
||||
'distalShuntDirection',
|
||||
),
|
||||
initialPressure,
|
||||
implantNotes:
|
||||
device.implantNotes === undefined
|
||||
? undefined
|
||||
: this.normalizeNullableString(device.implantNotes, 'implantNotes'),
|
||||
labelImageUrl:
|
||||
device.labelImageUrl === undefined
|
||||
? undefined
|
||||
: this.normalizeNullableString(
|
||||
device.labelImageUrl,
|
||||
'labelImageUrl',
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const surgery = await prisma.patientSurgery.create({
|
||||
data: {
|
||||
patientId,
|
||||
surgeryDate: this.normalizeIsoDate(dto.surgeryDate, 'surgeryDate'),
|
||||
surgeryName: this.normalizeRequiredString(
|
||||
dto.surgeryName,
|
||||
'surgeryName',
|
||||
),
|
||||
surgeonId: surgeon.id,
|
||||
surgeonName: surgeon.name,
|
||||
preOpPressure:
|
||||
dto.preOpPressure == null
|
||||
? null
|
||||
: this.normalizeNonNegativeInteger(
|
||||
dto.preOpPressure,
|
||||
'preOpPressure',
|
||||
),
|
||||
primaryDisease: this.normalizeRequiredString(
|
||||
dto.primaryDisease,
|
||||
'primaryDisease',
|
||||
),
|
||||
hydrocephalusTypes: this.normalizeStringArray(
|
||||
dto.hydrocephalusTypes,
|
||||
'hydrocephalusTypes',
|
||||
),
|
||||
previousShuntSurgeryDate: dto.previousShuntSurgeryDate
|
||||
? this.normalizeIsoDate(
|
||||
dto.previousShuntSurgeryDate,
|
||||
'previousShuntSurgeryDate',
|
||||
)
|
||||
: (latestSurgery?.surgeryDate ?? null),
|
||||
preOpMaterials:
|
||||
dto.preOpMaterials == null
|
||||
? undefined
|
||||
: this.normalizePreOpMaterials(dto.preOpMaterials),
|
||||
notes:
|
||||
dto.notes === undefined
|
||||
? undefined
|
||||
: this.normalizeNullableString(dto.notes, 'notes'),
|
||||
devices: {
|
||||
create: deviceDrafts,
|
||||
},
|
||||
},
|
||||
include: PATIENT_SURGERY_DETAIL_INCLUDE,
|
||||
});
|
||||
|
||||
if (abandonedDeviceIds.length > 0) {
|
||||
await prisma.device.updateMany({
|
||||
where: {
|
||||
id: { in: abandonedDeviceIds },
|
||||
patientId,
|
||||
},
|
||||
data: {
|
||||
isAbandoned: true,
|
||||
status: DeviceStatus.INACTIVE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return surgery;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并校验植入物型号字典。
|
||||
*/
|
||||
private async resolveImplantCatalogMap(
|
||||
prisma: PrismaExecutor,
|
||||
implantCatalogIds: number[],
|
||||
) {
|
||||
if (implantCatalogIds.length === 0) {
|
||||
return new Map<
|
||||
number,
|
||||
Awaited<ReturnType<typeof prisma.implantCatalog.findFirst>>
|
||||
>();
|
||||
}
|
||||
|
||||
const catalogs = await prisma.implantCatalog.findMany({
|
||||
where: {
|
||||
id: { in: implantCatalogIds },
|
||||
},
|
||||
select: IMPLANT_CATALOG_SELECT,
|
||||
});
|
||||
|
||||
if (catalogs.length !== implantCatalogIds.length) {
|
||||
throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND);
|
||||
}
|
||||
|
||||
return new Map(catalogs.map((catalog) => [catalog.id, catalog]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 可调压植入物若配置了挡位,录入压力时必须命中其中一项。
|
||||
*/
|
||||
private assertPressureLevelAllowed(
|
||||
catalog: {
|
||||
isPressureAdjustable: boolean;
|
||||
pressureLevels: string[];
|
||||
},
|
||||
pressure: string,
|
||||
) {
|
||||
if (
|
||||
catalog.isPressureAdjustable &&
|
||||
Array.isArray(catalog.pressureLevels) &&
|
||||
catalog.pressureLevels.length > 0 &&
|
||||
!catalog.pressureLevels.includes(pressure)
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID);
|
||||
}
|
||||
|
||||
return pressure;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将患者详情补全为前端可直接消费的结构。
|
||||
*/
|
||||
private decoratePatientDetail(
|
||||
patient: Awaited<ReturnType<BPatientsService['findPatientWithScope']>>,
|
||||
) {
|
||||
const surgeries = this.decorateSurgeries(patient.surgeries);
|
||||
|
||||
return {
|
||||
...patient,
|
||||
surgeries,
|
||||
shuntSurgeryCount: surgeries.length,
|
||||
latestSurgery: surgeries[0] ?? null,
|
||||
activeDeviceCount: patient.devices.filter(
|
||||
(device) =>
|
||||
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
|
||||
).length,
|
||||
abandonedDeviceCount: patient.devices.filter(
|
||||
(device) => device.isAbandoned,
|
||||
).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每次手术的自动分流手术次数。
|
||||
*/
|
||||
private decorateSurgeries<
|
||||
TSurgery extends {
|
||||
id: number;
|
||||
surgeryDate: Date;
|
||||
devices: Array<{
|
||||
id: number;
|
||||
status: DeviceStatus;
|
||||
isAbandoned: boolean;
|
||||
}>;
|
||||
},
|
||||
>(surgeries: TSurgery[]) {
|
||||
const sortedAsc = [...surgeries].sort(
|
||||
(left, right) =>
|
||||
new Date(left.surgeryDate).getTime() -
|
||||
new Date(right.surgeryDate).getTime(),
|
||||
);
|
||||
const sequenceById = new Map(
|
||||
sortedAsc.map((surgery, index) => [surgery.id, index + 1] as const),
|
||||
);
|
||||
|
||||
return [...surgeries]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.surgeryDate).getTime() -
|
||||
new Date(left.surgeryDate).getTime(),
|
||||
)
|
||||
.map((surgery) => ({
|
||||
...surgery,
|
||||
shuntSurgeryCount: sequenceById.get(surgery.id) ?? surgeries.length,
|
||||
activeDeviceCount: surgery.devices.filter(
|
||||
(device) =>
|
||||
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
|
||||
).length,
|
||||
abandonedDeviceCount: surgery.devices.filter(
|
||||
(device) => device.isAbandoned,
|
||||
).length,
|
||||
}));
|
||||
}
|
||||
|
||||
private normalizeRequiredString(value: unknown, fieldName: string) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(`${fieldName} 必须是字符串`);
|
||||
@ -873,14 +391,6 @@ export class BPatientsService {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private normalizeNullableString(value: unknown, fieldName: string) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(`${fieldName} 必须是字符串`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
private normalizePhone(phone: unknown) {
|
||||
const normalized = this.normalizeRequiredString(phone, 'phone');
|
||||
if (!/^1\d{10}$/.test(normalized)) {
|
||||
@ -896,62 +406,4 @@ export class BPatientsService {
|
||||
const normalized = this.normalizeRequiredString(value, 'idCard');
|
||||
return normalizePatientIdCard(normalized);
|
||||
}
|
||||
|
||||
private normalizeIsoDate(value: unknown, fieldName: string) {
|
||||
const normalized = this.normalizeRequiredString(value, fieldName);
|
||||
const parsed = new Date(normalized);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException(`${fieldName} 必须是合法日期`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private normalizeNonNegativeInteger(value: unknown, fieldName: string) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new BadRequestException(`${fieldName} 必须是大于等于 0 的整数`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private 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} 必须为非空数组`);
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
value.map((item) => this.normalizeRequiredString(item, fieldName)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private normalizePreOpMaterials(
|
||||
materials: CreatePatientSurgeryDto['preOpMaterials'],
|
||||
): Prisma.InputJsonArray {
|
||||
if (!Array.isArray(materials)) {
|
||||
throw new BadRequestException('preOpMaterials 必须是数组');
|
||||
}
|
||||
|
||||
return materials.map((material) => ({
|
||||
type: this.normalizeRequiredString(material.type, 'type'),
|
||||
url: this.normalizeRequiredString(material.url, 'url'),
|
||||
name:
|
||||
material.name === undefined
|
||||
? null
|
||||
: this.normalizeNullableString(material.name, 'name'),
|
||||
})) as Prisma.InputJsonArray;
|
||||
}
|
||||
|
||||
private toInt(value: unknown, fieldName: string) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new BadRequestException(`${fieldName} 必须为正整数`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,46 +32,8 @@ export class CPatientsService {
|
||||
},
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
surgeries: {
|
||||
include: {
|
||||
devices: {
|
||||
include: {
|
||||
implantCatalog: {
|
||||
select: {
|
||||
id: true,
|
||||
modelCode: true,
|
||||
manufacturer: true,
|
||||
name: true,
|
||||
pressureLevels: true,
|
||||
isPressureAdjustable: true,
|
||||
notes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { surgeryDate: 'desc' },
|
||||
},
|
||||
devices: {
|
||||
include: {
|
||||
surgery: {
|
||||
select: {
|
||||
id: true,
|
||||
surgeryDate: true,
|
||||
surgeryName: true,
|
||||
},
|
||||
},
|
||||
implantCatalog: {
|
||||
select: {
|
||||
id: true,
|
||||
modelCode: true,
|
||||
manufacturer: true,
|
||||
name: true,
|
||||
pressureLevels: true,
|
||||
isPressureAdjustable: true,
|
||||
notes: true,
|
||||
},
|
||||
},
|
||||
taskItems: {
|
||||
include: {
|
||||
task: true,
|
||||
@ -86,48 +48,8 @@ export class CPatientsService {
|
||||
}
|
||||
|
||||
const lifecycle = patients
|
||||
.flatMap((patient) => {
|
||||
const surgeryEvents = patient.surgeries.map(
|
||||
(surgery, index, surgeries) => ({
|
||||
eventType: 'SURGERY',
|
||||
occurredAt: surgery.surgeryDate,
|
||||
hospital: patient.hospital,
|
||||
patient: {
|
||||
id: this.toJsonNumber(patient.id),
|
||||
name: patient.name,
|
||||
inpatientNo: patient.inpatientNo,
|
||||
projectName: patient.projectName,
|
||||
},
|
||||
surgery: {
|
||||
id: this.toJsonNumber(surgery.id),
|
||||
surgeryDate: surgery.surgeryDate,
|
||||
surgeryName: surgery.surgeryName,
|
||||
surgeonName: surgery.surgeonName,
|
||||
primaryDisease: surgery.primaryDisease,
|
||||
hydrocephalusTypes: surgery.hydrocephalusTypes,
|
||||
previousShuntSurgeryDate: surgery.previousShuntSurgeryDate,
|
||||
shuntSurgeryCount: surgeries.length - index,
|
||||
},
|
||||
devices: surgery.devices.map((device) => ({
|
||||
id: this.toJsonNumber(device.id),
|
||||
status: device.status,
|
||||
isAbandoned: device.isAbandoned,
|
||||
currentPressure: device.currentPressure,
|
||||
initialPressure: device.initialPressure,
|
||||
implantModel: device.implantModel,
|
||||
implantManufacturer: device.implantManufacturer,
|
||||
implantName: device.implantName,
|
||||
isPressureAdjustable: device.isPressureAdjustable,
|
||||
shuntMode: device.shuntMode,
|
||||
distalShuntDirection: device.distalShuntDirection,
|
||||
proximalPunctureAreas: device.proximalPunctureAreas,
|
||||
valvePlacementSites: device.valvePlacementSites,
|
||||
implantCatalog: device.implantCatalog,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
const taskEvents = patient.devices.flatMap((device) =>
|
||||
.flatMap((patient) =>
|
||||
patient.devices.flatMap((device) =>
|
||||
device.taskItems.flatMap((taskItem) => {
|
||||
// 容错:若存在脏数据导致 task 为空,直接跳过该条明细,避免接口 500。
|
||||
if (!taskItem.task) {
|
||||
@ -143,26 +65,13 @@ export class CPatientsService {
|
||||
patient: {
|
||||
id: this.toJsonNumber(patient.id),
|
||||
name: patient.name,
|
||||
inpatientNo: patient.inpatientNo,
|
||||
projectName: patient.projectName,
|
||||
},
|
||||
device: {
|
||||
id: this.toJsonNumber(device.id),
|
||||
snCode: device.snCode,
|
||||
status: device.status,
|
||||
isAbandoned: device.isAbandoned,
|
||||
currentPressure: device.currentPressure,
|
||||
implantModel: device.implantModel,
|
||||
implantManufacturer: device.implantManufacturer,
|
||||
implantName: device.implantName,
|
||||
isPressureAdjustable: device.isPressureAdjustable,
|
||||
currentPressure: this.toJsonNumber(device.currentPressure),
|
||||
},
|
||||
surgery: device.surgery
|
||||
? {
|
||||
id: this.toJsonNumber(device.surgery.id),
|
||||
surgeryDate: device.surgery.surgeryDate,
|
||||
surgeryName: device.surgery.surgeryName,
|
||||
}
|
||||
: null,
|
||||
task: {
|
||||
id: this.toJsonNumber(task.id),
|
||||
status: task.status,
|
||||
@ -170,16 +79,14 @@ export class CPatientsService {
|
||||
},
|
||||
taskItem: {
|
||||
id: this.toJsonNumber(taskItem.id),
|
||||
oldPressure: taskItem.oldPressure,
|
||||
targetPressure: taskItem.targetPressure,
|
||||
oldPressure: this.toJsonNumber(taskItem.oldPressure),
|
||||
targetPressure: this.toJsonNumber(taskItem.targetPressure),
|
||||
},
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
return [...surgeryEvents, ...taskEvents];
|
||||
})
|
||||
),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||||
|
||||
@ -1,109 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsISO8601,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { CreateSurgeryDeviceDto } from './create-surgery-device.dto.js';
|
||||
import { SurgeryMaterialDto } from './surgery-material.dto.js';
|
||||
|
||||
/**
|
||||
* 患者手术 DTO:支持首术与二次手术录入。
|
||||
*/
|
||||
export class CreatePatientSurgeryDto {
|
||||
@ApiProperty({
|
||||
description: '手术日期',
|
||||
example: '2026-03-19T08:00:00.000Z',
|
||||
})
|
||||
@IsISO8601({}, { message: 'surgeryDate 必须是合法 ISO 日期' })
|
||||
surgeryDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '手术名称',
|
||||
example: '脑室腹腔分流术',
|
||||
})
|
||||
@IsString({ message: 'surgeryName 必须是字符串' })
|
||||
surgeryName!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '术前测压,可为空',
|
||||
example: 22,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'preOpPressure 必须是整数' })
|
||||
preOpPressure?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '原发病',
|
||||
example: '梗阻性脑积水',
|
||||
})
|
||||
@IsString({ message: 'primaryDisease 必须是字符串' })
|
||||
primaryDisease!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '脑积水类型,多选',
|
||||
type: [String],
|
||||
example: ['交通性', '高压性'],
|
||||
})
|
||||
@IsArray({ message: 'hydrocephalusTypes 必须是数组' })
|
||||
@ArrayMinSize(1, { message: 'hydrocephalusTypes 至少选择 1 项' })
|
||||
@IsString({ each: true, message: 'hydrocephalusTypes 必须为字符串数组' })
|
||||
hydrocephalusTypes!: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '上次分流手术时间,可为空',
|
||||
example: '2024-08-01T00:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601({}, { message: 'previousShuntSurgeryDate 必须是合法 ISO 日期' })
|
||||
previousShuntSurgeryDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '手术备注',
|
||||
example: '二次手术,弃用原右侧分流装置',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'notes 必须是字符串' })
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '术前 CT 影像/资料',
|
||||
type: [SurgeryMaterialDto],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'preOpMaterials 必须是数组' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SurgeryMaterialDto)
|
||||
preOpMaterials?: SurgeryMaterialDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '本次手术植入设备列表',
|
||||
type: [CreateSurgeryDeviceDto],
|
||||
})
|
||||
@IsArray({ message: 'devices 必须是数组' })
|
||||
@ArrayMinSize(1, { message: 'devices 至少录入 1 个设备' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateSurgeryDeviceDto)
|
||||
devices!: CreateSurgeryDeviceDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '本次手术后需弃用的历史设备 ID 列表',
|
||||
type: [Number],
|
||||
example: [1],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'abandonedDeviceIds 必须是数组' })
|
||||
@ArrayUnique({ message: 'abandonedDeviceIds 不能重复' })
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true, message: 'abandonedDeviceIds 必须为整数数组' })
|
||||
@Min(1, { each: true, message: 'abandonedDeviceIds 必须大于 0' })
|
||||
abandonedDeviceIds?: number[];
|
||||
}
|
||||
@ -1,14 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { CreatePatientSurgeryDto } from './create-patient-surgery.dto.js';
|
||||
import { IsInt, IsString, Matches, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 患者创建 DTO:B 端新增患者使用。
|
||||
@ -18,16 +10,6 @@ export class CreatePatientDto {
|
||||
@IsString({ message: 'name 必须是字符串' })
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '住院号', example: 'ZYH-20260319001' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'inpatientNo 必须是字符串' })
|
||||
inpatientNo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '项目名称', example: '脑积水随访项目' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'projectName 必须是字符串' })
|
||||
projectName?: string;
|
||||
|
||||
@ApiProperty({ description: '手机号', example: '13800002001' })
|
||||
@IsString({ message: 'phone 必须是字符串' })
|
||||
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||
@ -45,13 +27,4 @@ export class CreatePatientDto {
|
||||
@IsInt({ message: 'doctorId 必须是整数' })
|
||||
@Min(1, { message: 'doctorId 必须大于 0' })
|
||||
doctorId!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '首台手术信息,可在创建患者时一并录入',
|
||||
type: CreatePatientSurgeryDto,
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CreatePatientSurgeryDto)
|
||||
initialSurgery?: CreatePatientSurgeryDto;
|
||||
}
|
||||
|
||||
@ -1,85 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* 手术下设备 DTO:描述一次手术中植入的设备实例。
|
||||
*/
|
||||
export class CreateSurgeryDeviceDto {
|
||||
@ApiProperty({
|
||||
description: '植入物型号 ID,选中后自动回填厂家与名称',
|
||||
example: 1,
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'implantCatalogId 必须是整数' })
|
||||
@Min(1, { message: 'implantCatalogId 必须大于 0' })
|
||||
implantCatalogId!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '分流方式',
|
||||
example: 'VPS',
|
||||
})
|
||||
@IsString({ message: 'shuntMode 必须是字符串' })
|
||||
shuntMode!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '近端穿刺区域,最多 2 个',
|
||||
type: [String],
|
||||
example: ['额角', '枕角'],
|
||||
})
|
||||
@IsArray({ message: 'proximalPunctureAreas 必须是数组' })
|
||||
@ArrayMinSize(1, { message: 'proximalPunctureAreas 至少选择 1 项' })
|
||||
@ArrayMaxSize(2, { message: 'proximalPunctureAreas 最多选择 2 项' })
|
||||
@IsString({ each: true, message: 'proximalPunctureAreas 必须为字符串数组' })
|
||||
proximalPunctureAreas!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '阀门植入部位,最多 2 个',
|
||||
type: [String],
|
||||
example: ['耳后', '胸前'],
|
||||
})
|
||||
@IsArray({ message: 'valvePlacementSites 必须是数组' })
|
||||
@ArrayMinSize(1, { message: 'valvePlacementSites 至少选择 1 项' })
|
||||
@ArrayMaxSize(2, { message: 'valvePlacementSites 最多选择 2 项' })
|
||||
@IsString({ each: true, message: 'valvePlacementSites 必须为字符串数组' })
|
||||
valvePlacementSites!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '远端分流方向',
|
||||
example: '腹腔',
|
||||
})
|
||||
@IsString({ message: 'distalShuntDirection 必须是字符串' })
|
||||
distalShuntDirection!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '初始压力挡位,可为空',
|
||||
example: '1.5',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'initialPressure 必须是字符串' })
|
||||
initialPressure?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '植入物备注',
|
||||
example: '术中顺利,通畅良好',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'implantNotes 必须是字符串' })
|
||||
implantNotes?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '植入物标签图片地址',
|
||||
example: 'https://cdn.example.com/patients/device-label-001.jpg',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'labelImageUrl 必须是字符串' })
|
||||
labelImageUrl?: string;
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 手术资料 DTO:用于术前 CT 影像/视频等附件元数据。
|
||||
*/
|
||||
export class SurgeryMaterialDto {
|
||||
@ApiProperty({
|
||||
description: '资料类型',
|
||||
enum: ['IMAGE', 'VIDEO', 'FILE'],
|
||||
example: 'IMAGE',
|
||||
})
|
||||
@IsIn(['IMAGE', 'VIDEO', 'FILE'], {
|
||||
message: 'type 必须是 IMAGE、VIDEO 或 FILE',
|
||||
})
|
||||
type!: 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
@ApiProperty({
|
||||
description: '资料访问地址',
|
||||
example: 'https://cdn.example.com/patients/ct-001.png',
|
||||
})
|
||||
@IsString({ message: 'url 必须是字符串' })
|
||||
url!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '资料名称',
|
||||
example: '术前 CT 第 1 张',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'name 必须是字符串' })
|
||||
name?: string;
|
||||
}
|
||||
@ -1,10 +1,5 @@
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, 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';
|
||||
@ -16,8 +11,6 @@ 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 端任务控制器:封装调压任务状态流转接口。
|
||||
@ -30,78 +23,21 @@ 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.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DOCTOR,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: '发布任务(SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER)',
|
||||
})
|
||||
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER)
|
||||
@ApiOperation({ summary: '发布任务(DOCTOR/DIRECTOR/LEADER)' })
|
||||
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
|
||||
return this.taskService.publishTask(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工程师接收调压任务(当前流程已停用)。
|
||||
* 工程师接收调压任务。
|
||||
*/
|
||||
@Post('accept')
|
||||
@Roles(Role.ENGINEER)
|
||||
@ApiOperation({ summary: '接收任务(已停用)' })
|
||||
@ApiOperation({ summary: '接收任务(ENGINEER)' })
|
||||
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
|
||||
return this.taskService.acceptTask(actor, dto);
|
||||
}
|
||||
@ -117,19 +53,11 @@ export class BTasksController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统管理员/医院管理员/医生/主任/组长取消调压任务(仅任务创建者)。
|
||||
* 医生/主任/组长取消调压任务(仅任务创建者)。
|
||||
*/
|
||||
@Post('cancel')
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DOCTOR,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: '取消任务(SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER)',
|
||||
})
|
||||
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER)
|
||||
@ApiOperation({ summary: '取消任务(DOCTOR/DIRECTOR/LEADER)' })
|
||||
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
|
||||
return this.taskService.cancelTask(actor, dto);
|
||||
}
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
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,10 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsString,
|
||||
IsOptional,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@ -19,20 +20,23 @@ export class PublishTaskItemDto {
|
||||
@Min(1, { message: 'deviceId 必须大于 0' })
|
||||
deviceId!: number;
|
||||
|
||||
@ApiProperty({ description: '目标挡位标签', example: '1.5' })
|
||||
@IsString({ message: 'targetPressure 必须是字符串' })
|
||||
targetPressure!: string;
|
||||
@ApiProperty({ description: '目标压力值', example: 120 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'targetPressure 必须是整数' })
|
||||
targetPressure!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布任务 DTO。
|
||||
*/
|
||||
export class PublishTaskDto {
|
||||
@ApiProperty({ description: '接收工程师 ID', example: 2 })
|
||||
@ApiPropertyOptional({ description: '指定工程师 ID(可选)', example: 2 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'engineerId 必须是整数' })
|
||||
@Min(1, { message: 'engineerId 必须大于 0' })
|
||||
engineerId!: number;
|
||||
engineerId?: number;
|
||||
|
||||
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
|
||||
@IsArray({ message: 'items 必须是数组' })
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
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,7 +6,6 @@ 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';
|
||||
@ -14,9 +13,7 @@ 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';
|
||||
|
||||
/**
|
||||
* 任务服务:封装调压任务状态机、院内隔离与事件发布逻辑。
|
||||
@ -29,162 +26,11 @@ export class TaskService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询当前角色可指定的接收工程师列表。
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布任务:管理员或临床角色创建主任务与明细,并直接指定接收工程师。
|
||||
* 发布任务:医生/主任/组长创建主任务与明细,状态初始化为 PENDING。
|
||||
*/
|
||||
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
|
||||
this.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DOCTOR,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
]);
|
||||
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
if (!Array.isArray(dto.items) || dto.items.length === 0) {
|
||||
throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED);
|
||||
@ -196,98 +42,57 @@ 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: scopedHospitalId
|
||||
? {
|
||||
hospitalId: scopedHospitalId,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
currentPressure: true,
|
||||
patient: {
|
||||
select: {
|
||||
hospitalId: true,
|
||||
},
|
||||
},
|
||||
implantCatalog: {
|
||||
select: {
|
||||
pressureLevels: true,
|
||||
},
|
||||
},
|
||||
patient: { hospitalId },
|
||||
},
|
||||
select: { id: true, currentPressure: true },
|
||||
});
|
||||
|
||||
if (devices.length !== deviceIds.length) {
|
||||
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
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);
|
||||
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 pressureByDeviceId = new Map(
|
||||
devices.map((device) => [device.id, device.currentPressure] as const),
|
||||
);
|
||||
const pressureLevelsByDeviceId = new Map(
|
||||
devices.map((device) => [
|
||||
device.id,
|
||||
Array.isArray(device.implantCatalog?.pressureLevels)
|
||||
? device.implantCatalog.pressureLevels
|
||||
: [],
|
||||
]),
|
||||
);
|
||||
|
||||
dto.items.forEach((item) => {
|
||||
const normalizedTargetPressure = this.normalizeTargetPressure(
|
||||
item.targetPressure,
|
||||
);
|
||||
const pressureLevels = pressureLevelsByDeviceId.get(item.deviceId) ?? [];
|
||||
if (
|
||||
pressureLevels.length > 0 &&
|
||||
!pressureLevels.includes(normalizedTargetPressure)
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID);
|
||||
}
|
||||
|
||||
item.targetPressure = normalizedTargetPressure;
|
||||
});
|
||||
|
||||
const task = await this.prisma.task.create({
|
||||
data: {
|
||||
status: TaskStatus.ACCEPTED,
|
||||
status: TaskStatus.PENDING,
|
||||
creatorId: actor.id,
|
||||
engineerId: engineer.id,
|
||||
engineerId: dto.engineerId ?? null,
|
||||
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,
|
||||
})),
|
||||
},
|
||||
@ -306,10 +111,68 @@ export class TaskService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收任务:当前流程已停用,统一改为发布时直接指定接收工程师。
|
||||
* 接收任务:工程师将任务从 PENDING 流转到 ACCEPTED。
|
||||
*/
|
||||
async acceptTask(_actor: ActorContext, _dto: AcceptTaskDto) {
|
||||
throw new ForbiddenException(MESSAGES.TASK.ACCEPT_DISABLED);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -372,19 +235,13 @@ export class TaskService {
|
||||
* 取消任务:任务创建者可将 PENDING/ACCEPTED 任务取消。
|
||||
*/
|
||||
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
||||
this.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DOCTOR,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
]);
|
||||
const scopedHospitalId = this.resolveScopedHospitalId(actor);
|
||||
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
const task = await this.prisma.task.findFirst({
|
||||
where: {
|
||||
id: dto.taskId,
|
||||
hospitalId: scopedHospitalId ?? undefined,
|
||||
hospitalId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@ -435,154 +292,7 @@ export class TaskService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回角色可见的医院范围。系统管理员可不绑定医院,按目标设备自动归院。
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 工程师侧任务流转仍要求明确的院内身份。
|
||||
* 校验并返回 hospitalId(B 端强依赖租户隔离)。
|
||||
*/
|
||||
private requireHospitalId(actor: ActorContext): number {
|
||||
if (!actor.hospitalId) {
|
||||
|
||||
@ -1,133 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
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 {}
|
||||
@ -1,18 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -1,383 +0,0 @@
|
||||
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 {
|
||||
// 临时文件清理失败不影响主流程,避免吞掉原始错误。
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,5 @@
|
||||
import { Body, Controller, Param, Patch, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||
|
||||
@ -31,10 +31,7 @@ export class CreateUserDto {
|
||||
@MinLength(8, { message: 'password 长度至少 8 位' })
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '微信 openId',
|
||||
example: 'wx-open-id-demo',
|
||||
})
|
||||
@ApiPropertyOptional({ description: '微信 openId', example: 'wx-open-id-demo' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'openId 必须是字符串' })
|
||||
openId?: string;
|
||||
|
||||
@ -30,10 +30,7 @@ export class LoginDto {
|
||||
@IsEnum(Role, { message: 'role 枚举值不合法' })
|
||||
role!: Role;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '医院 ID(多账号场景建议传入)',
|
||||
example: 1,
|
||||
})
|
||||
@ApiPropertyOptional({ description: '医院 ID(多账号场景建议传入)', example: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
|
||||
@ -38,7 +38,7 @@ export class UsersController {
|
||||
* 创建用户。
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@ApiOperation({ summary: '创建用户' })
|
||||
create(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@ -61,7 +61,7 @@ export class UsersController {
|
||||
* 查询用户详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@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)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@ApiOperation({ summary: '更新用户' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
update(
|
||||
@ -87,7 +87,7 @@ export class UsersController {
|
||||
* 删除用户。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.DIRECTOR)
|
||||
@ApiOperation({ summary: '删除用户' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
|
||||
|
||||
@ -30,9 +30,6 @@ 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) {}
|
||||
@ -205,18 +202,15 @@ export class UsersService {
|
||||
actor.hospitalId,
|
||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
||||
);
|
||||
} else if (actor.role === Role.DIRECTOR) {
|
||||
} else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
|
||||
where.hospitalId = this.requireActorScopeInt(
|
||||
actor.hospitalId,
|
||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
||||
);
|
||||
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);
|
||||
}
|
||||
@ -688,7 +682,36 @@ export class UsersService {
|
||||
}
|
||||
|
||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
@ -717,7 +740,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取权限:系统管理员可读全量;院管可读本院。
|
||||
* 读取权限:系统管理员可读全量;院管可读本院;主任可读本科室医生。
|
||||
*/
|
||||
private assertUserReadable(
|
||||
actor: ActorContext,
|
||||
@ -726,7 +749,6 @@ export class UsersService {
|
||||
role: Role;
|
||||
hospitalId: number | null;
|
||||
departmentId: number | null;
|
||||
groupId: number | null;
|
||||
},
|
||||
) {
|
||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||
@ -747,36 +769,30 @@ 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.departmentId === actorDepartmentId &&
|
||||
(DIRECTOR_VISIBLE_ROLES as readonly Role[]).includes(target.role)
|
||||
target.role === Role.DOCTOR &&
|
||||
target.hospitalId === actorHospitalId &&
|
||||
target.departmentId === actorDepartmentId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写权限:院管可写本院非管理员账号。
|
||||
* 写权限:院管可写本院非管理员账号;主任仅可写本科室医生。
|
||||
*/
|
||||
private assertUserWritable(
|
||||
actor: ActorContext,
|
||||
@ -791,6 +807,25 @@ 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);
|
||||
}
|
||||
@ -822,6 +857,10 @@ 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)
|
||||
@ -839,6 +878,17 @@ 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;
|
||||
}
|
||||
@ -861,9 +911,17 @@ export class UsersService {
|
||||
actor: ActorContext,
|
||||
departmentId: number | null,
|
||||
) {
|
||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||
if (actor.role !== Role.DIRECTOR) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actorDepartmentId = this.requireActorScopeInt(
|
||||
actor.departmentId,
|
||||
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
||||
);
|
||||
if (departmentId !== actorDepartmentId) {
|
||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
|
Before Width: | Height: | Size: 137 B |
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 137 B |
@ -1 +0,0 @@
|
||||
upload-SYSTEM_ADMIN
|
||||
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 137 B |
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 137 B |
@ -1 +0,0 @@
|
||||
upload-SYSTEM_ADMIN
|
||||
@ -1 +0,0 @@
|
||||
fake-image-content
|
||||
@ -1 +0,0 @@
|
||||
fake-image-content
|
||||
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 72 B |
|
Before Width: | Height: | Size: 72 B |
|
Before Width: | Height: | Size: 72 B |
|
Before Width: | Height: | Size: 72 B |
|
Before Width: | Height: | Size: 72 B |
|
Before Width: | Height: | Size: 72 B |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 72 B |
@ -1 +0,0 @@
|
||||
upload-HOSPITAL_ADMIN
|
||||
@ -1 +0,0 @@
|
||||
upload-DIRECTOR
|
||||