Compare commits

..

2 Commits

Author SHA1 Message Date
EL
2bfe8ac8c8 新增上传资产模型与迁移,支持 IMAGE、VIDEO、FILE 三类资产管理
新增 B 端上传接口与列表接口,统一文件上传和分页查询能力
上传能力支持医院级数据隔离:系统管理员需显式指定医院,院内角色按登录医院自动隔离
图片上传自动压缩并转为 webp,视频上传自动转码并压缩为 mp4,普通文件按原始类型存储
增加上传目录与公开访问能力,统一输出可直接预览的访问地址
前端新增影像库页面,支持按类型筛选、关键字检索、分页浏览、在线预览与原文件访问
前端新增通用上传组件,支持在页面内复用并返回上传结果
管理后台新增影像库菜单与路由,并补充页面级角色权限控制
患者手术相关表单接入上传复用能力,支持术前资料与设备标签上传回填
新增上传模块 e2e 用例,覆盖成功路径、权限矩阵与关键失败场景
补充上传模块文档与安装依赖说明,完善工程内使用说明
2026-03-20 04:35:43 +08:00
EL
73082225f6 "1. 新增系统字典与全局植入目录相关表结构及迁移
2. 扩展患者手术与材料模型,更新种子数据
3. 新增字典模块,增强设备植入目录管理能力
4. 重构患者后台服务与表单链路,统一权限与参数校验
5. 管理台新增字典页面并改造患者/设备页面与路由权限
6. 补充字典及相关领域 e2e 测试并更新文档"
2026-03-19 20:42:17 +08:00
150 changed files with 10759 additions and 1880 deletions

View File

@ -2,17 +2,57 @@
## 1. 目标 ## 1. 目标
- 提供 B 端设备 CRUD。 - 提供“全局植入物目录”管理,供患者手术表单选择。
- 管理设备与患者的归属关系。 - 维护患者手术下的植入实例记录。
- 支持管理员按医院、患者、状态和关键词分页查询设备。 - 支持为可调压器械配置挡位列表。
- 支持管理员按医院、患者、状态和关键词分页查询患者植入实例。
## 2. 权限 ## 2. 设备实例
- `SYSTEM_ADMIN`:可跨院查询和维护设备。 `Device` 现在表示“患者某次手术下的植入设备实例”,不是独立库存主数据。
- `HOSPITAL_ADMIN`:仅可操作本院患者名下设备。
- 其他角色:默认拒绝。
## 3. 接口 核心字段:
- `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. 接口
设备实例:
- `GET /b/devices`:分页查询设备列表 - `GET /b/devices`:分页查询设备列表
- `GET /b/devices/:id`:查询设备详情 - `GET /b/devices/:id`:查询设备详情
@ -20,8 +60,18 @@
- `PATCH /b/devices/:id`:更新设备 - `PATCH /b/devices/:id`:更新设备
- `DELETE /b/devices/:id`:删除设备 - `DELETE /b/devices/:id`:删除设备
## 4. 约束 型号字典:
- `GET /b/devices/catalogs`:查询植入物型号字典
- `POST /b/devices/catalogs`:新增植入物目录
- `PATCH /b/devices/catalogs/:id`:更新植入物目录
- `DELETE /b/devices/catalogs/:id`:删除植入物目录
## 5. 约束
- 设备必须绑定到一个患者。 - 设备必须绑定到一个患者。
- 设备 SN 在全库唯一,服务端会统一转成大写后再校验。
- 删除已被任务明细引用的设备会返回 `409` - 删除已被任务明细引用的设备会返回 `409`
- 删除已被患者手术引用的植入物目录会返回 `409`
- 可调压植入物若配置了 `pressureLevels`,患者手术录入和任务调压时的压力值必须命中该挡位列表。
- 调压任务仅允许针对 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备发布。
- `Device.currentPressure` 只允许由调压任务完成时更新,患者手术录入和设备实例编辑都不开放手工写入。

42
docs/dictionaries.md Normal file
View File

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

View File

@ -4,17 +4,17 @@
- 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。 - 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。
- 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。 - 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。
- 测试前固定执行数据库重置与 seed,确保结果可重复。 - 测试前固定执行数据库重置,并通过真实接口全流程建数,确保结果可重复。
## 2. 风险提示 ## 2. 风险提示
`pnpm test:e2e` 会执行: `pnpm test:e2e` 会执行:
1. `prisma migrate reset --force` 1. `prisma migrate reset --force`
2. `node prisma/seed.mjs` 2. 启动 Jest 后,由测试用例通过真实 HTTP 接口完成基础夹具创建
这会清空 `.env``DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。 这会清空 `.env``DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
另外,seed 账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。 另外,接口引导创建的测试账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
## 3. 运行命令 ## 3. 运行命令
@ -22,7 +22,7 @@
pnpm test:e2e pnpm test:e2e
``` ```
仅重置数据库并注入 seed 仅重置数据库并重新生成 Prisma Client
```bash ```bash
pnpm test:e2e:prepare pnpm test:e2e:prepare
@ -34,7 +34,7 @@ pnpm test:e2e:prepare
pnpm test:e2e:watch pnpm test:e2e:watch
``` ```
## 4. 种子账号(默认密码:`Seed@1234` ## 4. 接口引导夹具(默认密码:`Seed@1234`
- 系统管理员:`13800001000` - 系统管理员:`13800001000`
- 院管(医院 A`13800001001` - 院管(医院 A`13800001001`
@ -42,20 +42,37 @@ pnpm test:e2e:watch
- 组长(医院 A`13800001003` - 组长(医院 A`13800001003`
- 医生(医院 A`13800001004` - 医生(医院 A`13800001004`
- 工程师(医院 A`13800001005` - 工程师(医院 A`13800001005`
- 院管(医院 B`13800001011`
- 工程师(医院 B`13800001015`
说明:
- 这些账号不再由 `prisma/seed.mjs` 直写生成。
- 每次执行 E2E 时,会先创建系统管理员,再通过后台接口依次创建医院、院管、医生、工程师、目录、患者、手术和调压任务。
- 因为夹具是通过真实业务接口生成的,所以权限、作用域、删除保护和调压链路都能在同一套测试里被覆盖。
## 5. 用例结构 ## 5. 用例结构
- `test/e2e/specs/auth.e2e-spec.ts` - `test/e2e/specs/auth.e2e-spec.ts`
- `test/e2e/specs/users.e2e-spec.ts` - `test/e2e/specs/users.e2e-spec.ts`
- `test/e2e/specs/organization.e2e-spec.ts` - `test/e2e/specs/organization.e2e-spec.ts`
- `test/e2e/specs/dictionaries.e2e-spec.ts`
- `test/e2e/specs/devices.e2e-spec.ts`
- `test/e2e/specs/tasks.e2e-spec.ts` - `test/e2e/specs/tasks.e2e-spec.ts`
- `test/e2e/specs/patients.e2e-spec.ts` - `test/e2e/specs/patients.e2e-spec.ts`
- `test/e2e/specs/auth-token-revocation.e2e-spec.ts`
## 6. 覆盖策略 ## 6. 覆盖策略
- 受保护接口27 个):每个接口覆盖 6 角色访问结果 + 未登录 401。 - 受保护接口27 个):每个接口覆盖 6 角色访问结果 + 未登录 401。
- 非受保护接口3 个):每个接口至少 1 个成功 + 1 个失败。 - 非受保护接口3 个):每个接口至少 1 个成功 + 1 个失败。
- 关键行为额外覆盖: - 关键行为额外覆盖:
- 从创建系统管理员开始的完整接口建数链路
- 任务状态机冲突409 - 任务状态机冲突409
- 调压任务发布后不改当前压力,完成任务后才回写设备当前压力
- 主刀医生自动跟随患者归属医生,且历史手术保留快照
- 患者 B 端角色可见性 - 患者 B 端角色可见性
- 患者创建人返回与展示
- 跨院工程师隔离
- 组织域院管作用域限制与删除冲突 - 组织域院管作用域限制与删除冲突
- 目录、设备、组织、用户的删除保护

View File

@ -5,10 +5,11 @@
- 登录页:`/auth/login`,支持可选 `hospitalId` - 登录页:`/auth/login`,支持可选 `hospitalId`
- 首页看板:按角色拉取组织与患者统计。 - 首页看板:按角色拉取组织与患者统计。
- 设备页:新增管理员专用设备 CRUD复用真实设备接口。 - 设备页:新增管理员专用设备 CRUD复用真实设备接口。
- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。 - 任务页:改为只读调压记录页,接入真实任务列表接口。
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。 - 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard` - 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`
后端直接保存身份证号原文,不再做哈希转换。 后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。
- 新增影像库页:接入真实上传接口,支持图片/视频/文件上传与分页查看。
## 2. 接口契约对齐点 ## 2. 接口契约对齐点
@ -16,23 +17,27 @@
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。 - `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。 - `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
- `GET /b/devices` 已支持服务端分页与筛选,前端直接透传 `page/pageSize` - `GET /b/devices` 已支持服务端分页与筛选,前端直接透传 `page/pageSize`
- `GET /b/tasks` 返回 `{ list, total, page, pageSize }`,供任务页只读展示调压记录。
- `POST /b/uploads` 使用 `multipart/form-data` 上传文件,返回上传资产元数据。
- `GET /b/uploads` 返回 `{ list, total, page, pageSize }`,仅供系统管理员/医院管理员影像库分页展示。
- `GET /b/tasks/engineers` 返回可选接收工程师列表,患者页发布调压任务前需要先拉取。
- `GET /c/patients/lifecycle` 必须同时传 `phone``idCard` - `GET /c/patients/lifecycle` 必须同时传 `phone``idCard`
- 患者表单中的 `idCard` 字段直接传身份证号; - 患者表单中的 `idCard` 字段直接传身份证号;
服务端只会做去空格与 `x/X` 标准化,不会转哈希。 服务端只会做去空格与 `x/X` 标准化,不会转哈希。
- 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。 - 患者手术、调压任务、设备目录中的压力值全部按字符串挡位标签传输,例如 `0.5``1``1.5``10`
## 3. 角色权限提示 ## 3. 角色权限提示
- 任务接口权限: - 任务接口权限:
- `DOCTOR/DIRECTOR/LEADER`:发布、取消(仅可取消自己创建的任务) - `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时必须指定接收工程师;可取消自己创建的任务
- `ENGINEER`接收、完成 - `ENGINEER`仅可完成分配给自己的任务
- 患者列表权限: - 患者列表权限:
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId` - `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
- 用户管理接口: - 用户管理接口:
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR` 可访问列表与创建 - `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可创建、编辑、删除
- `DIRECTOR` 页面语义调整为“医生管理”,仅管理本科室医生 - `DIRECTOR` 可只读查看本科室下级医生/组长
- 工程师绑定医院仅 `SYSTEM_ADMIN` - 工程师绑定医院仅 `SYSTEM_ADMIN`
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`DIRECTOR` 可删除本科室无关联医生 - 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`HOSPITAL_ADMIN` 可删除本院无关联、且非管理员用户
## 3.1 结构图页面交互调整 ## 3.1 结构图页面交互调整
@ -46,27 +51,29 @@
`SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR``LEADER` 可访问 `SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR``LEADER` 可访问
- `organization/departments` - `organization/departments`
`SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问
- `users``SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR` 可访问 - `users``SYSTEM_ADMIN``HOSPITAL_ADMIN` 可管理;`DIRECTOR` 可只读查看
- `devices`:仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问 - `devices`:仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问
- `uploads`:仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问
- `organization/hospitals` - `organization/hospitals`
- 仅 `SYSTEM_ADMIN` 可访问 - 仅 `SYSTEM_ADMIN` 可访问
- `tasks` - `tasks`
- `DOCTOR``DIRECTOR``LEADER``ENGINEER` 可访问 - `SYSTEM_ADMIN``HOSPITAL_ADMIN``DOCTOR``DIRECTOR``LEADER``ENGINEER` 可访问
- `patients` - `patients`
- `SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR``LEADER``DOCTOR` 可访问 - `SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR``LEADER``DOCTOR` 可访问
患者页负责发起调压任务并指定接收人,任务页仅查看调压记录,不再提供发布或接收入口。
患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。
前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403` 前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`
## 3.3 主任/组长组织管理范围 ## 3.3 主任/组长组织管理范围
- `DIRECTOR` - `DIRECTOR`
- 可查看组织架构、小组列表(限定本科室范围) - 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
- 可创建/编辑/删除本科室下小组
- 可进入“医生管理”页,创建/维护本科室医生
- `LEADER` - `LEADER`
- 可查看组织架构、小组列表(限定本科室/本小组范围) - 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
- 可编辑本小组名称 - 主任/组长不再显示“科室管理”“小组管理”“用户管理”页面。
- 主任/组长不再显示独立“科室管理”页面。
- 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 显示。 - 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 显示。
## 4. 本地运行 ## 4. 本地运行

View File

@ -2,33 +2,109 @@
## 1. 目标 ## 1. 目标
- B 端:按组织与角色范围查询患者(强依赖 `hospitalId` - B 端:按组织与角色范围查询患者,并维护患者基础档案
- C 端:按 `phone + idCard` 做跨院聚合查询 - B 端:支持患者首术录入、二次手术追加、旧设备弃用标记
- 患者档案直接保存身份证号原文,不再做哈希转换 - 数据关系升级为 `Patient -> PatientSurgery -> Device -> TaskItem -> Task`
- 服务端只做轻量格式整理:去空格、统一末尾 `x/X` 为大写 - C 端:按 `phone + idCard` 做跨院生命周期聚合,返回手术事件与调压事件
## 2. B 端可见性 ## 2. 患者基础档案
患者表新增以下字段:
- `inpatientNo`:住院号
- `projectName`:项目名称
- `phone`:联系电话
- `idCard`:身份证号原文
- `doctorId`:患者归属人员(医生/主任/组长)
说明:
- `name` 仍然保留为患者姓名必填字段。
- `doctorId + hospitalId` 仍然是患者的组织归属来源,不直接绑定小组/科室。
- 手术表单中的 `原发病 / 脑积水类型 / 分流方式 / 近端穿刺区域 / 阀门植入部位 / 远端分流方向` 改为读取系统字典,不再前端硬编码。
## 3. 手术档案
新增 `PatientSurgery` 表,每次手术保存:
- `surgeryDate`:手术日期
- `surgeryName`:手术名称
- `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 端可见性
- `DOCTOR`:仅可查自己名下患者 - `DOCTOR`:仅可查自己名下患者
- `LEADER`:可查本组医生名下患者(按医生当前 `groupId` 反查) - `LEADER`:可查本组医生名下患者
- `DIRECTOR`:可查本科室医生名下患者(按医生当前 `departmentId` 反查) - `DIRECTOR`:可查本科室医生名下患者
- `HOSPITAL_ADMIN`:可查本院全部患者 - `HOSPITAL_ADMIN`:可查本院全部患者
- `SYSTEM_ADMIN`:需显式传入目标 `hospitalId` - `SYSTEM_ADMIN`:需显式传入目标 `hospitalId`
## 2.1 B 端 CRUD ## 6. B 端接口
- `GET /b/patients`:按角色查询可见患者 - `GET /b/patients`:按角色查询可见患者列表
- `GET /b/patients/doctors`:查询当前角色可见的归属人员候选(医生/主任/组长,用于患者表单) - `GET /b/patients/doctors`:查询当前角色可见的归属人员候选
- `POST /b/patients`:创建患者 - `POST /b/patients`:创建患者,可选带 `initialSurgery`
- `POST /b/patients/:id/surgeries`:为患者新增手术
- `GET /b/patients/:id`:查询患者详情 - `GET /b/patients/:id`:查询患者详情
- `PATCH /b/patients/:id`:更新患者 - `PATCH /b/patients/:id`:更新患者基础信息
- `DELETE /b/patients/:id`:删除患者(若存在关联设备返回 409 - `DELETE /b/patients/:id`:删除患者
说明: 约束:
患者表只绑定 `doctorId + hospitalId`,不直接绑定小组/科室。医生调组或调科后,
可见范围会按医生当前组织归属自动变化,无需迁移患者数据。
## 3. C 端生命周期聚合 - `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。
- 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`
- 患者建档时会自动记录 `creator`,列表和详情都会返回创建人信息。
## 7. C 端生命周期聚合
接口:`GET /c/patients/lifecycle?phone=...&idCard=...` 接口:`GET /c/patients/lifecycle?phone=...&idCard=...`
@ -36,10 +112,20 @@
1. 不做医院隔离(跨租户) 1. 不做医院隔离(跨租户)
2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配 2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配
3. 关联查询 `Patient -> Device -> TaskItem -> Task` 3. 聚合 `Patient -> PatientSurgery -> Device` 的手术事件
4. 返回扁平生命周期列表(按 `Task.createdAt DESC` 4. 聚合 `Patient -> Device -> TaskItem -> Task` 的调压事件
5. 全部事件按 `occurredAt DESC` 返回
## 4. 响应结构 事件类型:
- `SURGERY`
- `TASK_PRESSURE_ADJUSTMENT`
说明:
- 生命周期中的 `initialPressure / currentPressure / oldPressure / targetPressure` 均返回字符串挡位标签。
## 8. 响应结构
全部接口统一返回: 全部接口统一返回:

View File

@ -7,34 +7,47 @@
## 2. 状态机 ## 2. 状态机
- `PENDING -> ACCEPTED -> COMPLETED` - 当前发布流程:`ACCEPTED -> COMPLETED`
- `PENDING/ACCEPTED -> CANCELLED` - 当前取消流程:`ACCEPTED -> CANCELLED`
- 历史兼容:保留 `PENDING` 状态枚举,便于兼容旧数据与旧任务记录
非法流转会返回 `409` 冲突错误(中文消息)。 非法流转会返回 `409` 冲突错误(中文消息)。
## 3. 角色权限 ## 3. 角色权限
- 医生/主任/组长:发布任务、取消自己创建的任务 - 系统管理员/医院管理员/医生/主任/组长:发布任务时必须直接指定接收工程师,并且只能取消自己创建的任务
- 工程师:接收任务、完成自己接收的任务 - 工程师:不能再执行“接收”动作,只能完成指派给自己的任务
- 其他角色:默认拒绝 - 其他角色:默认拒绝
补充: 补充:
- `GET /b/tasks/engineers`:返回当前角色可选的接收工程师列表,系统管理员可按医院筛选。
- `GET /b/tasks`:返回当前角色可见的调压记录列表,系统管理员可按医院筛选。
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。 - `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。 - 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
## 4. 事件触发 ## 4. 记录列表
- 后台任务页不再承担手工发布入口,只展示调压记录。
- 记录维度按 `TaskItem` 展开,每条记录会携带:
- 任务状态
- 患者信息
- 手术名称
- 设备信息
- 旧压力 / 目标压力 / 当前压力(均为字符串挡位标签)
- 创建人 / 接收人 / 发布时间
## 5. 事件触发
状态变化后会发出事件: 状态变化后会发出事件:
- `task.published` - `task.published`
- `task.accepted`
- `task.completed` - `task.completed`
- `task.cancelled` - `task.cancelled`
用于后续接入微信通知或消息中心。 用于后续接入微信通知或消息中心。
## 5. 完成任务时的设备同步 ## 6. 完成任务时的设备同步
`completeTask` 在单事务中执行: `completeTask` 在单事务中执行:
@ -43,3 +56,8 @@
3. 批量更新关联 `Device.currentPressure` 3. 批量更新关联 `Device.currentPressure`
确保任务状态与设备压力一致性。 确保任务状态与设备压力一致性。
补充:
- `publishTask` 只负责生成任务和目标挡位,不会立刻修改设备当前压力。
- 只有工程师完成任务后,目标挡位才会回写到设备实例。

71
docs/uploads.md Normal file
View File

@ -0,0 +1,71 @@
# 上传资产模块说明(`src/uploads`
## 1. 目标
- 提供图片、视频、文件的统一上传入口。
- 为 B 端“影像库/视频库/文件库”页面提供分页查询。
- 为患者手术表单中的术前资料、植入物标签上传提供复用能力。
## 2. 数据模型
新增 `UploadAsset` 表,保存上传文件元数据:
- `hospitalId`:医院归属
- `creatorId`:上传人
- `type``IMAGE / VIDEO / FILE`
- `originalName`:原始文件名
- `fileName`:服务端生成文件名
- `storagePath`:相对存储路径
- `url`:公开访问地址,前端直接用于预览
- `mimeType`:文件 MIME 类型
- `fileSize`:文件大小(字节)
文件本体默认落盘到:
- 公开目录:`storage/uploads`
- 临时目录:`storage/tmp-uploads`
- 最终目录规则:`storage/uploads/YYYY/MM/DD`
- 最终文件名规则:`YYYYMMDDHHmmss-原文件名`
- 图片压缩后扩展名统一为 `.webp`
- 视频压缩后扩展名统一为 `.mp4`
- 如同一秒内出现同名文件,会自动追加 `-1``-2` 防止覆盖
## 3. 接口
- `POST /b/uploads`
- 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN / DIRECTOR / LEADER / DOCTOR`
- 表单字段:
- `file`:二进制文件
- `hospitalId`:仅 `SYSTEM_ADMIN` 上传时必填
- `GET /b/uploads`
- 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN`
- 查询参数:
- `keyword`
- `type`
- `hospitalId`:仅 `SYSTEM_ADMIN` 可选
- `page`
- `pageSize`
## 4. 使用说明
- 患者手术表单中的“术前 CT 影像/资料”支持直接上传,上传成功后自动回填 `type/name/url`
- 设备表单中的“植入物标签”支持直接上传图片,上传成功后自动回填 `labelImageUrl`
- 患者详情页会直接预览术前图片、视频和设备标签。
- 单独新增“影像库”页面,按图片/视频/文件分页查看所有上传资产。
- 页面访问权限仅 `SYSTEM_ADMIN / HOSPITAL_ADMIN`
## 5. 压缩策略
- 图片上传后会自动压缩并统一转成 `webp`
- 自动纠正旋转方向
- 最大边限制为 `2560`
- 返回的 `mimeType``image/webp`
- 视频上传后会自动压缩并统一转成 `mp4`
- 最大边限制为 `1280`
- 视频编码为 `H.264`
- 音频编码为 `AAC`
- 返回的 `mimeType``video/mp4`
- 普通文件类型不做转码,按原文件保存。
- 如果本地 `pnpm install` 屏蔽了依赖安装脚本,`ffmpeg-static` 二进制不会自动落盘,视频压缩会失败。
- 这种情况下手动执行:
- `node node_modules/.pnpm/ffmpeg-static@5.3.0/node_modules/ffmpeg-static/install.js`

View File

@ -18,8 +18,10 @@
- 医院内数据按 `hospitalId` 强隔离。 - 医院内数据按 `hospitalId` 强隔离。
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。 - 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。 - `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可执行用户创建、编辑、删除。
- `DIRECTOR` 可创建、查看、编辑、删除本科室医生,但不能跨科室操作,也不能把医生改成其他角色。 - `DIRECTOR` 仅可只读查看本科室下级医生/组长。
- `LEADER` 仅可只读查看本小组医生列表。
- `HOSPITAL_ADMIN` 仅可操作本院非管理员账号。
- 用户组织字段校验: - 用户组织字段校验:
- 院管/医生/工程师等需有医院归属; - 院管/医生/工程师等需有医院归属;
- 主任/组长需有科室/小组等必要归属; - 主任/组长需有科室/小组等必要归属;
@ -32,12 +34,22 @@
- `GET /users``GET /users/:id``PATCH /users/:id``DELETE /users/:id` - `GET /users``GET /users/:id``PATCH /users/:id``DELETE /users/:id`
- `POST /b/users/:id/assign-engineer-hospital` - `POST /b/users/:id/assign-engineer-hospital`
其中院管侧的常用链路为:
- `POST /users`:创建本院用户
- `GET /users/:id`:查看本院用户详情
- `PATCH /users/:id`:修改本院用户信息
- `DELETE /users/:id`:删除本院无关联、且非管理员用户
其中主任侧的常用链路为: 其中主任侧的常用链路为:
- `POST /users`:创建本科室医生 - `GET /users`:查看本科室下级医生/组长
- `GET /users/:id`:查看本科室医生详情 - `GET /users/:id`:查看本科室下级详情
- `PATCH /users/:id`:修改本科室医生信息
- `DELETE /users/:id`:删除无关联数据的本科室医生 其中组长侧的常用链路为:
- `GET /users`:查看本小组医生列表
- `GET /users/:id`:查看本小组医生详情
## 5. 开发改造建议 ## 5. 开发改造建议

View File

@ -13,7 +13,7 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate && node prisma/seed.mjs", "test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate",
"test:e2e": "pnpm test:e2e:prepare && NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --runInBand", "test:e2e": "pnpm test:e2e:prepare && NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --runInBand",
"test:e2e:watch": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --watch" "test:e2e:watch": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --watch"
}, },
@ -30,10 +30,13 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"ffmpeg-static": "^5.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"pg": "^8.20.0", "pg": "^8.20.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.34.5",
"swagger-ui-express": "^5.0.1" "swagger-ui-express": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
@ -44,6 +47,7 @@
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"globals": "^16.0.0", "globals": "^16.0.0",

388
pnpm-lock.yaml generated
View File

@ -44,9 +44,15 @@ importers:
dotenv: dotenv:
specifier: ^17.3.1 specifier: ^17.3.1
version: 17.3.1 version: 17.3.1
ffmpeg-static:
specifier: ^5.3.0
version: 5.3.0
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.3 specifier: ^9.0.3
version: 9.0.3 version: 9.0.3
multer:
specifier: ^2.1.1
version: 2.1.1
pg: pg:
specifier: ^8.20.0 specifier: ^8.20.0
version: 8.20.0 version: 8.20.0
@ -56,6 +62,9 @@ importers:
rxjs: rxjs:
specifier: ^7.8.1 specifier: ^7.8.1
version: 7.8.2 version: 7.8.2
sharp:
specifier: ^0.34.5
version: 0.34.5
swagger-ui-express: swagger-ui-express:
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.1(express@5.2.1) version: 5.0.1(express@5.2.1)
@ -81,6 +90,9 @@ importers:
'@types/jsonwebtoken': '@types/jsonwebtoken':
specifier: ^9.0.10 specifier: ^9.0.10
version: 9.0.10 version: 9.0.10
'@types/multer':
specifier: ^2.1.0
version: 2.1.0
'@types/node': '@types/node':
specifier: ^22.10.7 specifier: ^22.10.7
version: 22.19.15 version: 22.19.15
@ -342,6 +354,10 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@derhuerst/http-basic@8.2.4':
resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==}
engines: {node: '>=6.0.0'}
'@electric-sql/pglite-socket@0.0.20': '@electric-sql/pglite-socket@0.0.20':
resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==} resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==}
hasBin: true hasBin: true
@ -371,6 +387,159 @@ packages:
peerDependencies: peerDependencies:
hono: ^4 hono: ^4
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@inquirer/ansi@1.0.2': '@inquirer/ansi@1.0.2':
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -932,6 +1101,12 @@ packages:
'@types/ms@2.1.0': '@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@2.1.0':
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@22.19.15': '@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
@ -1147,6 +1322,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ajv-formats@2.1.1: ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies: peerDependencies:
@ -1368,6 +1547,9 @@ packages:
caniuse-lite@1.0.30001778: caniuse-lite@1.0.30001778:
resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==} resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==}
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1573,6 +1755,10 @@ packages:
destr@2.0.5: destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
detect-newline@3.1.0: detect-newline@3.1.0:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1633,6 +1819,10 @@ packages:
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
error-ex@1.3.4: error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@ -1736,6 +1926,10 @@ packages:
fb-watchman@2.0.2: fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
ffmpeg-static@5.3.0:
resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==}
engines: {node: '>=16'}
file-type@21.3.0: file-type@21.3.0:
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
engines: {node: '>=20'} engines: {node: '>=20'}
@ -1896,9 +2090,16 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
http-response-object@3.0.2:
resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
http-status-codes@2.3.0: http-status-codes@2.3.0:
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
human-signals@2.1.0: human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'} engines: {node: '>=10.17.0'}
@ -2465,6 +2666,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
parse-cache-control@1.0.1:
resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==}
parse-json@5.2.0: parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2639,6 +2843,10 @@ packages:
typescript: typescript:
optional: true optional: true
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
proper-lockfile@4.1.2: proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
@ -2778,6 +2986,10 @@ packages:
setprototypeof@1.2.0: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -3491,6 +3703,13 @@ snapshots:
dependencies: dependencies:
'@jridgewell/trace-mapping': 0.3.9 '@jridgewell/trace-mapping': 0.3.9
'@derhuerst/http-basic@8.2.4':
dependencies:
caseless: 0.12.0
concat-stream: 2.0.0
http-response-object: 3.0.2
parse-cache-control: 1.0.1
'@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)':
dependencies: dependencies:
'@electric-sql/pglite': 0.3.15 '@electric-sql/pglite': 0.3.15
@ -3521,6 +3740,102 @@ snapshots:
dependencies: dependencies:
hono: 4.11.4 hono: 4.11.4
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.9.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@inquirer/ansi@1.0.2': {} '@inquirer/ansi@1.0.2': {}
'@inquirer/checkbox@4.3.2(@types/node@22.19.15)': '@inquirer/checkbox@4.3.2(@types/node@22.19.15)':
@ -4247,6 +4562,12 @@ snapshots:
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
'@types/multer@2.1.0':
dependencies:
'@types/express': 5.0.6
'@types/node@10.17.60': {}
'@types/node@22.19.15': '@types/node@22.19.15':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@ -4452,6 +4773,12 @@ snapshots:
acorn@8.16.0: {} acorn@8.16.0: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
ajv-formats@2.1.1(ajv@8.18.0): ajv-formats@2.1.1(ajv@8.18.0):
optionalDependencies: optionalDependencies:
ajv: 8.18.0 ajv: 8.18.0
@ -4699,6 +5026,8 @@ snapshots:
caniuse-lite@1.0.30001778: {} caniuse-lite@1.0.30001778: {}
caseless@0.12.0: {}
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@ -4864,6 +5193,8 @@ snapshots:
destr@2.0.5: {} destr@2.0.5: {}
detect-libc@2.1.2: {}
detect-newline@3.1.0: {} detect-newline@3.1.0: {}
dezalgo@1.0.4: dezalgo@1.0.4:
@ -4913,6 +5244,8 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.0 tapable: 2.3.0
env-paths@2.2.1: {}
error-ex@1.3.4: error-ex@1.3.4:
dependencies: dependencies:
is-arrayish: 0.2.1 is-arrayish: 0.2.1
@ -5035,6 +5368,15 @@ snapshots:
dependencies: dependencies:
bser: 2.1.1 bser: 2.1.1
ffmpeg-static@5.3.0:
dependencies:
'@derhuerst/http-basic': 8.2.4
env-paths: 2.2.1
https-proxy-agent: 5.0.1
progress: 2.0.3
transitivePeerDependencies:
- supports-color
file-type@21.3.0: file-type@21.3.0:
dependencies: dependencies:
'@tokenizer/inflate': 0.4.1 '@tokenizer/inflate': 0.4.1
@ -5229,8 +5571,19 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 toidentifier: 1.0.1
http-response-object@3.0.2:
dependencies:
'@types/node': 10.17.60
http-status-codes@2.3.0: {} http-status-codes@2.3.0: {}
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
human-signals@2.1.0: {} human-signals@2.1.0: {}
iconv-lite@0.7.2: iconv-lite@0.7.2:
@ -5926,6 +6279,8 @@ snapshots:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
parse-cache-control@1.0.1: {}
parse-json@5.2.0: parse-json@5.2.0:
dependencies: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
@ -6076,6 +6431,8 @@ snapshots:
- react - react
- react-dom - react-dom
progress@2.0.3: {}
proper-lockfile@4.1.2: proper-lockfile@4.1.2:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@ -6223,6 +6580,37 @@ snapshots:
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
shebang-regex: 3.0.0 shebang-regex: 3.0.0

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- ffmpeg-static
- sharp

View File

@ -0,0 +1,82 @@
-- AlterTable
ALTER TABLE "Device" ADD COLUMN "distalShuntDirection" TEXT,
ADD COLUMN "implantCatalogId" INTEGER,
ADD COLUMN "implantManufacturer" TEXT,
ADD COLUMN "implantModel" TEXT,
ADD COLUMN "implantName" TEXT,
ADD COLUMN "implantNotes" TEXT,
ADD COLUMN "initialPressure" INTEGER,
ADD COLUMN "isAbandoned" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "isPressureAdjustable" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "labelImageUrl" TEXT,
ADD COLUMN "proximalPunctureAreas" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "shuntMode" TEXT,
ADD COLUMN "surgeryId" INTEGER,
ADD COLUMN "valvePlacementSites" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "Patient" ADD COLUMN "inpatientNo" TEXT,
ADD COLUMN "projectName" TEXT;
-- CreateTable
CREATE TABLE "PatientSurgery" (
"id" SERIAL NOT NULL,
"patientId" INTEGER NOT NULL,
"surgeryDate" TIMESTAMP(3) NOT NULL,
"surgeryName" TEXT NOT NULL,
"surgeonName" TEXT NOT NULL,
"preOpPressure" INTEGER,
"primaryDisease" TEXT NOT NULL,
"hydrocephalusTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
"previousShuntSurgeryDate" TIMESTAMP(3),
"preOpMaterials" JSONB,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PatientSurgery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImplantCatalog" (
"id" SERIAL NOT NULL,
"modelCode" TEXT NOT NULL,
"manufacturer" TEXT NOT NULL,
"name" TEXT NOT NULL,
"hospitalId" INTEGER,
"isPressureAdjustable" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "ImplantCatalog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PatientSurgery_patientId_surgeryDate_idx" ON "PatientSurgery"("patientId", "surgeryDate");
-- CreateIndex
CREATE UNIQUE INDEX "ImplantCatalog_modelCode_key" ON "ImplantCatalog"("modelCode");
-- CreateIndex
CREATE INDEX "ImplantCatalog_hospitalId_idx" ON "ImplantCatalog"("hospitalId");
-- CreateIndex
CREATE INDEX "Device_surgeryId_idx" ON "Device"("surgeryId");
-- CreateIndex
CREATE INDEX "Device_implantCatalogId_idx" ON "Device"("implantCatalogId");
-- CreateIndex
CREATE INDEX "Device_patientId_isAbandoned_idx" ON "Device"("patientId", "isAbandoned");
-- CreateIndex
CREATE INDEX "Patient_inpatientNo_idx" ON "Patient"("inpatientNo");
-- AddForeignKey
ALTER TABLE "PatientSurgery" ADD CONSTRAINT "PatientSurgery_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImplantCatalog" ADD CONSTRAINT "ImplantCatalog_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_surgeryId_fkey" FOREIGN KEY ("surgeryId") REFERENCES "PatientSurgery"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_implantCatalogId_fkey" FOREIGN KEY ("implantCatalogId") REFERENCES "ImplantCatalog"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,21 @@
-- CreateEnum
CREATE TYPE "DictionaryType" AS ENUM ('PRIMARY_DISEASE', 'HYDROCEPHALUS_TYPE', 'SHUNT_MODE', 'PROXIMAL_PUNCTURE_AREA', 'VALVE_PLACEMENT_SITE', 'DISTAL_SHUNT_DIRECTION');
-- CreateTable
CREATE TABLE "DictionaryItem" (
"id" SERIAL NOT NULL,
"type" "DictionaryType" NOT NULL,
"label" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DictionaryItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "DictionaryItem_type_enabled_sortOrder_idx" ON "DictionaryItem"("type", "enabled", "sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "DictionaryItem_type_label_key" ON "DictionaryItem"("type", "label");

View File

@ -0,0 +1,15 @@
-- AlterTable
ALTER TABLE "ImplantCatalog"
ADD COLUMN "pressureLevels" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
ADD COLUMN "notes" TEXT,
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- DropForeignKey
ALTER TABLE "ImplantCatalog" DROP CONSTRAINT IF EXISTS "ImplantCatalog_hospitalId_fkey";
-- DropIndex
DROP INDEX IF EXISTS "ImplantCatalog_hospitalId_idx";
-- DropColumn
ALTER TABLE "ImplantCatalog" DROP COLUMN IF EXISTS "hospitalId";

View File

@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "Patient"
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "creatorId" INTEGER;
-- Backfill
UPDATE "Patient"
SET "creatorId" = "doctorId"
WHERE "creatorId" IS NULL;
-- AlterTable
ALTER TABLE "Patient"
ALTER COLUMN "creatorId" SET NOT NULL;
-- CreateIndex
CREATE INDEX "Patient_creatorId_idx" ON "Patient"("creatorId");
-- AddForeignKey
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX IF EXISTS "Device_snCode_key";
-- AlterTable
ALTER TABLE "Device" DROP COLUMN "snCode";

View File

@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "Device" ALTER COLUMN "currentPressure" SET DATA TYPE TEXT,
ALTER COLUMN "initialPressure" SET DATA TYPE TEXT;
-- AlterTable
ALTER TABLE "ImplantCatalog" ALTER COLUMN "pressureLevels" SET DATA TYPE TEXT[];
-- AlterTable
ALTER TABLE "PatientSurgery" ADD COLUMN "surgeonId" INTEGER;
-- AlterTable
ALTER TABLE "TaskItem" ALTER COLUMN "oldPressure" SET DATA TYPE TEXT,
ALTER COLUMN "targetPressure" SET DATA TYPE TEXT;
-- CreateIndex
CREATE INDEX "PatientSurgery_surgeonId_idx" ON "PatientSurgery"("surgeonId");
-- AddForeignKey
ALTER TABLE "PatientSurgery" ADD CONSTRAINT "PatientSurgery_surgeonId_fkey" FOREIGN KEY ("surgeonId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,34 @@
-- CreateEnum
CREATE TYPE "UploadAssetType" AS ENUM ('IMAGE', 'VIDEO', 'FILE');
-- CreateTable
CREATE TABLE "UploadAsset" (
"id" SERIAL NOT NULL,
"hospitalId" INTEGER NOT NULL,
"creatorId" INTEGER NOT NULL,
"type" "UploadAssetType" NOT NULL,
"originalName" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"storagePath" TEXT NOT NULL,
"url" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UploadAsset_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UploadAsset_storagePath_key" ON "UploadAsset"("storagePath");
-- CreateIndex
CREATE INDEX "UploadAsset_hospitalId_type_createdAt_idx" ON "UploadAsset"("hospitalId", "type", "createdAt");
-- CreateIndex
CREATE INDEX "UploadAsset_creatorId_createdAt_idx" ON "UploadAsset"("creatorId", "createdAt");
-- AddForeignKey
ALTER TABLE "UploadAsset" ADD CONSTRAINT "UploadAsset_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UploadAsset" ADD CONSTRAINT "UploadAsset_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -36,6 +36,23 @@ enum TaskStatus {
CANCELLED 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 { model Hospital {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
@ -44,6 +61,7 @@ model Hospital {
users User[] users User[]
patients Patient[] patients Patient[]
tasks Task[] tasks Task[]
uploads UploadAsset[]
} }
// 科室表:归属于医院。 // 科室表:归属于医院。
@ -88,8 +106,11 @@ model User {
// 小组删除必须先清理成员,避免静默把用户 groupId 置空。 // 小组删除必须先清理成员,避免静默把用户 groupId 置空。
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict) group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
doctorPatients Patient[] @relation("DoctorPatients") doctorPatients Patient[] @relation("DoctorPatients")
createdPatients Patient[] @relation("PatientCreator")
createdTasks Task[] @relation("TaskCreator") createdTasks Task[] @relation("TaskCreator")
acceptedTasks Task[] @relation("TaskEngineer") acceptedTasks Task[] @relation("TaskEngineer")
surgeonSurgeries PatientSurgery[] @relation("SurgerySurgeon")
createdUploads UploadAsset[] @relation("UploadCreator")
@@unique([phone, role, hospitalId]) @@unique([phone, role, hospitalId])
@@index([phone]) @@index([phone])
@ -102,30 +123,137 @@ model User {
model Patient { model Patient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
createdAt DateTime @default(now())
// 住院号:用于院内患者检索与病案关联。
inpatientNo String?
// 项目名称:用于区分患者所属项目/课题。
projectName String?
phone String phone String
// 患者身份证号,录入与查询都使用原始证件号。 // 患者身份证号,录入与查询都使用原始证件号。
idCard String idCard String
hospitalId Int hospitalId Int
doctorId Int doctorId Int
creatorId Int
hospital Hospital @relation(fields: [hospitalId], references: [id]) hospital Hospital @relation(fields: [hospitalId], references: [id])
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id]) doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
creator User @relation("PatientCreator", fields: [creatorId], references: [id])
surgeries PatientSurgery[]
devices Device[] devices Device[]
@@index([phone, idCard]) @@index([phone, idCard])
@@index([hospitalId, doctorId]) @@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 { model Device {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
snCode String @unique currentPressure String
currentPressure Int
status DeviceStatus @default(ACTIVE) status DeviceStatus @default(ACTIVE)
patientId Int patientId Int
surgeryId Int?
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]) patient Patient @relation(fields: [patientId], references: [id])
surgery PatientSurgery? @relation(fields: [surgeryId], references: [id], onDelete: SetNull)
implantCatalog ImplantCatalog? @relation(fields: [implantCatalogId], references: [id], onDelete: SetNull)
taskItems TaskItem[] taskItems TaskItem[]
@@index([patientId, status]) @@index([patientId, status])
@@index([surgeryId])
@@index([implantCatalogId])
@@index([patientId, isAbandoned])
} }
// 主任务表:记录调压任务主单。 // 主任务表:记录调压任务主单。
@ -149,8 +277,8 @@ model TaskItem {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
taskId Int taskId Int
deviceId Int deviceId Int
oldPressure Int oldPressure String
targetPressure Int targetPressure String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
device Device @relation(fields: [deviceId], references: [id]) device Device @relation(fields: [deviceId], references: [id])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,19 +58,22 @@ export const MESSAGES = {
'检测到多个同手机号账号,请传 hospitalId 指定登录医院', '检测到多个同手机号账号,请传 hospitalId 指定登录医院',
CREATE_FORBIDDEN: '当前角色无权限创建该用户', CREATE_FORBIDDEN: '当前角色无权限创建该用户',
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号', HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生账号', DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生或组长账号',
}, },
TASK: { TASK: {
ITEMS_REQUIRED: '任务明细 items 不能为空', ITEMS_REQUIRED: '任务明细 items 不能为空',
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在', DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院',
ENGINEER_REQUIRED: '接收工程师必选',
ENGINEER_INVALID: '工程师必须为当前医院有效工程师', ENGINEER_INVALID: '工程师必须为当前医院有效工程师',
TASK_NOT_FOUND: '任务不存在或不属于当前医院', TASK_NOT_FOUND: '任务不存在或不属于当前医院',
ACCEPT_ONLY_PENDING: '仅待接收任务可执行接收', ACCEPT_DISABLED: '当前流程不支持工程师接收,请由创建人直接指定接收工程师',
COMPLETE_ONLY_ACCEPTED: '仅已接收任务可执行完成', ACCEPT_ONLY_PENDING: '仅待指派任务可执行接收',
CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消', COMPLETE_ONLY_ACCEPTED: '仅已指派任务可执行完成',
ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收', CANCEL_ONLY_PENDING_ACCEPTED: '仅待指派/已指派任务可取消',
ENGINEER_ONLY_ASSIGNEE: '仅任务接收工程师可完成任务', ENGINEER_ALREADY_ASSIGNED: '任务已指派给其他工程师',
ENGINEER_ONLY_ASSIGNEE: '仅任务接收人可完成任务',
CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务', CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务',
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作', ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息', ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
@ -89,19 +92,47 @@ export const MESSAGES = {
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号', LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号',
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId', SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息', ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
SURGERY_ITEMS_REQUIRED: '手术下至少需要录入一个植入设备',
SURGERY_NOT_FOUND: '手术记录不存在或无权限访问',
IMPLANT_CATALOG_NOT_FOUND: '植入物型号不存在或不在当前医院可见范围内',
SURGERY_UPDATE_NOT_SUPPORTED:
'患者更新接口不支持直接修改手术,请使用新增手术接口',
ABANDON_DEVICE_SCOPE_FORBIDDEN: '仅可弃用当前患者名下设备',
}, },
DEVICE: { DEVICE: {
NOT_FOUND: '设备不存在或无权限访问', NOT_FOUND: '设备不存在或无权限访问',
SN_CODE_REQUIRED: 'snCode 不能为空', CURRENT_PRESSURE_INVALID: 'currentPressure 必须是合法挡位标签',
SN_CODE_DUPLICATE: '设备 SN 已存在',
CURRENT_PRESSURE_INVALID: 'currentPressure 必须为大于等于 0 的整数',
STATUS_INVALID: '设备状态不合法', STATUS_INVALID: '设备状态不合法',
PATIENT_REQUIRED: 'patientId 必填且必须为整数', PATIENT_REQUIRED: 'patientId 必填且必须为整数',
PATIENT_NOT_FOUND: '归属患者不存在', PATIENT_NOT_FOUND: '归属患者不存在',
PATIENT_SCOPE_FORBIDDEN: '仅可绑定当前权限范围内患者', PATIENT_SCOPE_FORBIDDEN: '仅可绑定当前权限范围内患者',
DELETE_CONFLICT: '设备存在关联任务记录,无法删除', DELETE_CONFLICT: '设备存在关联任务记录,无法删除',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息', ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
CATALOG_NOT_FOUND: '植入物型号不存在',
CATALOG_MODEL_DUPLICATE: '植入物型号编码已存在',
CATALOG_SCOPE_FORBIDDEN: '当前角色无权限维护该植入物型号',
CATALOG_DELETE_CONFLICT: '植入物型号已被患者手术引用,无法删除',
PRESSURE_LEVEL_INVALID: '压力值不在该植入物配置的挡位范围内',
DEVICE_NOT_ADJUSTABLE: '仅可调压设备允许创建调压任务',
},
DICTIONARY: {
NOT_FOUND: '字典项不存在',
LABEL_REQUIRED: '字典项名称不能为空',
DUPLICATE: '同类型下字典项名称已存在',
SYSTEM_ADMIN_ONLY_MAINTAIN: '仅系统管理员可维护字典',
},
UPLOAD: {
FILE_REQUIRED: '请先选择要上传的文件',
UNSUPPORTED_FILE_TYPE: '仅支持图片、视频、PDF/Office 文档上传',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息,无法上传文件',
SYSTEM_ADMIN_HOSPITAL_REQUIRED:
'系统管理员上传文件时必须显式指定 hospitalId',
INVALID_IMAGE_FILE: '上传的图片无法解析或压缩失败',
INVALID_VIDEO_FILE: '上传的视频无法解析或压缩失败',
FFMPEG_NOT_AVAILABLE: '服务端缺少视频压缩能力',
}, },
ORG: { ORG: {

View File

@ -0,0 +1,56 @@
import { BadRequestException } from '@nestjs/common';
/**
*
*/
export function normalizePressureLabel(value: unknown, fieldName: string) {
const raw =
typeof value === 'string' || typeof value === 'number'
? String(value).trim()
: '';
if (!raw) {
throw new BadRequestException(`${fieldName} 不能为空`);
}
if (!/^\d+(\.\d+)?$/.test(raw)) {
throw new BadRequestException(`${fieldName} 必须是合法挡位标签`);
}
const [integerPart, fractionPart = ''] = raw.split('.');
const normalizedInteger = integerPart.replace(/^0+(?=\d)/, '') || '0';
const normalizedFraction = fractionPart.replace(/0+$/, '');
return normalizedFraction
? `${normalizedInteger}.${normalizedFraction}`
: normalizedInteger;
}
/**
*
*/
export function comparePressureLabel(left: string, right: string) {
const leftNumber = Number(left);
const rightNumber = Number(right);
if (leftNumber !== rightNumber) {
return leftNumber - rightNumber;
}
return left.localeCompare(right, 'en');
}
/**
*
*/
export function normalizePressureLabelList(
values: unknown[] | undefined,
fieldName: string,
) {
if (!Array.isArray(values) || values.length === 0) {
return [];
}
return Array.from(
new Set(values.map((value) => normalizePressureLabel(value, fieldName))),
).sort(comparePressureLabel);
}

View File

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

View File

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

View File

@ -29,20 +29,26 @@ export class DepartmentsService {
*/ */
async create(actor: ActorContext, dto: CreateDepartmentDto) { async create(actor: ActorContext, dto: CreateDepartmentDto) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]); this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const hospitalId = this.access.toInt(dto.hospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED); const hospitalId = this.access.toInt(
dto.hospitalId,
MESSAGES.ORG.HOSPITAL_ID_REQUIRED,
);
await this.access.ensureHospitalExists(hospitalId); await this.access.ensureHospitalExists(hospitalId);
this.access.assertHospitalScope(actor, hospitalId); this.access.assertHospitalScope(actor, hospitalId);
return this.prisma.department.create({ return this.prisma.department.create({
data: { data: {
name: this.access.normalizeName(dto.name, MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED), name: this.access.normalizeName(
dto.name,
MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED,
),
hospitalId, hospitalId,
}, },
}); });
} }
/** /**
* / *
*/ */
async findAll(actor: ActorContext, query: OrganizationQueryDto) { async findAll(actor: ActorContext, query: OrganizationQueryDto) {
this.access.assertRole(actor, [ this.access.assertRole(actor, [
@ -50,6 +56,7 @@ export class DepartmentsService {
Role.HOSPITAL_ADMIN, Role.HOSPITAL_ADMIN,
Role.DIRECTOR, Role.DIRECTOR,
Role.LEADER, Role.LEADER,
Role.DOCTOR,
]); ]);
const paging = this.access.resolvePaging(query); const paging = this.access.resolvePaging(query);
const where: Prisma.DepartmentWhereInput = {}; const where: Prisma.DepartmentWhereInput = {};
@ -60,7 +67,11 @@ export class DepartmentsService {
if (actor.role === Role.HOSPITAL_ADMIN) { if (actor.role === Role.HOSPITAL_ADMIN) {
where.hospitalId = this.access.requireActorHospitalId(actor); where.hospitalId = this.access.requireActorHospitalId(actor);
} else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) { } else if (
actor.role === Role.DIRECTOR ||
actor.role === Role.LEADER ||
actor.role === Role.DOCTOR
) {
where.id = this.access.requireActorDepartmentId(actor); where.id = this.access.requireActorDepartmentId(actor);
} else if (query.hospitalId != null) { } else if (query.hospitalId != null) {
where.hospitalId = this.access.toInt( where.hospitalId = this.access.toInt(
@ -73,7 +84,10 @@ export class DepartmentsService {
this.prisma.department.count({ where }), this.prisma.department.count({ where }),
this.prisma.department.findMany({ this.prisma.department.findMany({
where, where,
include: { hospital: true, _count: { select: { users: true, groups: true } } }, include: {
hospital: true,
_count: { select: { users: true, groups: true } },
},
skip: paging.skip, skip: paging.skip,
take: paging.take, take: paging.take,
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
@ -84,7 +98,7 @@ export class DepartmentsService {
} }
/** /**
* / *
*/ */
async findOne(actor: ActorContext, id: number) { async findOne(actor: ActorContext, id: number) {
this.access.assertRole(actor, [ this.access.assertRole(actor, [
@ -92,8 +106,12 @@ export class DepartmentsService {
Role.HOSPITAL_ADMIN, Role.HOSPITAL_ADMIN,
Role.DIRECTOR, Role.DIRECTOR,
Role.LEADER, 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({ const department = await this.prisma.department.findUnique({
where: { id: departmentId }, where: { id: departmentId },
include: { include: {
@ -106,18 +124,21 @@ export class DepartmentsService {
} }
if (actor.role === Role.HOSPITAL_ADMIN) { if (actor.role === Role.HOSPITAL_ADMIN) {
this.access.assertHospitalScope(actor, department.hospitalId); this.access.assertHospitalScope(actor, department.hospitalId);
} } else if (
if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) { actor.role === Role.DIRECTOR ||
actor.role === Role.LEADER ||
actor.role === Role.DOCTOR
) {
const actorDepartmentId = this.access.requireActorDepartmentId(actor); const actorDepartmentId = this.access.requireActorDepartmentId(actor);
if (department.id !== actorDepartmentId) { if (department.id !== actorDepartmentId) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
} }
} }
return department; return department;
} }
/** /**
* / *
*/ */
async update(actor: ActorContext, id: number, dto: UpdateDepartmentDto) { async update(actor: ActorContext, id: number, dto: UpdateDepartmentDto) {
const current = await this.findOne(actor, id); const current = await this.findOne(actor, id);
@ -128,7 +149,10 @@ export class DepartmentsService {
} }
if (dto.name !== undefined) { if (dto.name !== undefined) {
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED); data.name = this.access.normalizeName(
dto.name,
MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED,
);
} }
return this.prisma.department.update({ return this.prisma.department.update({

View File

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

View File

@ -9,16 +9,35 @@ import { Prisma } from '../generated/prisma/client.js';
import { DeviceStatus, Role } from '../generated/prisma/enums.js'; import { DeviceStatus, Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js'; import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js'; import { MESSAGES } from '../common/messages.js';
import {
normalizePressureLabelList,
normalizePressureLabel,
} from '../common/pressure-level.util.js';
import { PrismaService } from '../prisma.service.js'; import { PrismaService } from '../prisma.service.js';
import { CreateImplantCatalogDto } from './dto/create-implant-catalog.dto.js';
import { CreateDeviceDto } from './dto/create-device.dto.js'; import { CreateDeviceDto } from './dto/create-device.dto.js';
import { DeviceQueryDto } from './dto/device-query.dto.js'; import { DeviceQueryDto } from './dto/device-query.dto.js';
import { UpdateImplantCatalogDto } from './dto/update-implant-catalog.dto.js';
import { UpdateDeviceDto } from './dto/update-device.dto.js'; import { UpdateDeviceDto } from './dto/update-device.dto.js';
const CATALOG_SELECT = {
id: true,
modelCode: true,
manufacturer: true,
name: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
createdAt: true,
updatedAt: true,
} as const;
const DEVICE_DETAIL_INCLUDE = { const DEVICE_DETAIL_INCLUDE = {
patient: { patient: {
select: { select: {
id: true, id: true,
name: true, name: true,
inpatientNo: true,
phone: true, phone: true,
hospitalId: true, hospitalId: true,
hospital: { hospital: {
@ -36,6 +55,17 @@ const DEVICE_DETAIL_INCLUDE = {
}, },
}, },
}, },
surgery: {
select: {
id: true,
surgeryDate: true,
surgeryName: true,
surgeonName: true,
},
},
implantCatalog: {
select: CATALOG_SELECT,
},
_count: { _count: {
select: { select: {
taskItems: true, taskItems: true,
@ -44,7 +74,7 @@ const DEVICE_DETAIL_INCLUDE = {
} as const; } as const;
/** /**
* CRUD * CRUD
*/ */
@Injectable() @Injectable()
export class DevicesService { export class DevicesService {
@ -107,14 +137,12 @@ export class DevicesService {
async create(actor: ActorContext, dto: CreateDeviceDto) { async create(actor: ActorContext, dto: CreateDeviceDto) {
this.assertAdmin(actor); this.assertAdmin(actor);
const snCode = this.normalizeSnCode(dto.snCode);
const patient = await this.resolveWritablePatient(actor, dto.patientId); const patient = await this.resolveWritablePatient(actor, dto.patientId);
await this.assertSnCodeUnique(snCode);
return this.prisma.device.create({ return this.prisma.device.create({
data: { data: {
snCode, // 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。
currentPressure: this.normalizePressure(dto.currentPressure), currentPressure: '0',
status: dto.status ?? DeviceStatus.ACTIVE, status: dto.status ?? DeviceStatus.ACTIVE,
patientId: patient.id, patientId: patient.id,
}, },
@ -123,20 +151,12 @@ export class DevicesService {
} }
/** /**
* SN *
*/ */
async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) { async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) {
const current = await this.findOne(actor, id); const current = await this.findOne(actor, id);
const data: Prisma.DeviceUpdateInput = {}; const data: Prisma.DeviceUpdateInput = {};
if (dto.snCode !== undefined) {
const snCode = this.normalizeSnCode(dto.snCode);
await this.assertSnCodeUnique(snCode, current.id);
data.snCode = snCode;
}
if (dto.currentPressure !== undefined) {
data.currentPressure = this.normalizePressure(dto.currentPressure);
}
if (dto.status !== undefined) { if (dto.status !== undefined) {
data.status = this.normalizeStatus(dto.status); data.status = this.normalizeStatus(dto.status);
} }
@ -174,6 +194,142 @@ export class DevicesService {
} }
} }
/**
*
*/
async findCatalogs(actor: ActorContext, keyword?: string) {
this.assertCatalogReadable(actor);
const where = this.buildCatalogWhere(keyword);
return this.prisma.implantCatalog.findMany({
where,
select: CATALOG_SELECT,
orderBy: [{ manufacturer: 'asc' }, { name: 'asc' }, { modelCode: 'asc' }],
});
}
/**
*
*/
async createCatalog(actor: ActorContext, dto: CreateImplantCatalogDto) {
this.assertSystemAdmin(actor);
const isPressureAdjustable = dto.isPressureAdjustable ?? true;
try {
return await this.prisma.implantCatalog.create({
data: {
modelCode: this.normalizeModelCode(dto.modelCode),
manufacturer: this.normalizeRequiredString(
dto.manufacturer,
'manufacturer',
),
name: this.normalizeRequiredString(dto.name, 'name'),
pressureLevels: this.normalizePressureLevels(
dto.pressureLevels,
isPressureAdjustable,
),
isPressureAdjustable,
notes:
dto.notes === undefined
? undefined
: this.normalizeNullableString(dto.notes, 'notes'),
},
select: CATALOG_SELECT,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE);
}
throw error;
}
}
/**
*
*/
async updateCatalog(
actor: ActorContext,
id: number,
dto: UpdateImplantCatalogDto,
) {
this.assertSystemAdmin(actor);
const current = await this.findWritableCatalog(id);
const nextIsPressureAdjustable =
dto.isPressureAdjustable ?? current.isPressureAdjustable;
const data: Prisma.ImplantCatalogUpdateInput = {};
if (dto.modelCode !== undefined) {
data.modelCode = this.normalizeModelCode(dto.modelCode);
}
if (dto.manufacturer !== undefined) {
data.manufacturer = this.normalizeRequiredString(
dto.manufacturer,
'manufacturer',
);
}
if (dto.name !== undefined) {
data.name = this.normalizeRequiredString(dto.name, 'name');
}
if (dto.isPressureAdjustable !== undefined) {
data.isPressureAdjustable = dto.isPressureAdjustable;
}
if (
dto.pressureLevels !== undefined ||
dto.isPressureAdjustable !== undefined
) {
data.pressureLevels = this.normalizePressureLevels(
dto.pressureLevels ?? current.pressureLevels,
nextIsPressureAdjustable,
);
}
if (dto.notes !== undefined) {
data.notes = this.normalizeNullableString(dto.notes, 'notes');
}
try {
return await this.prisma.implantCatalog.update({
where: { id: current.id },
data,
select: CATALOG_SELECT,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE);
}
throw error;
}
}
/**
* 409
*/
async removeCatalog(actor: ActorContext, id: number) {
this.assertSystemAdmin(actor);
const current = await this.findWritableCatalog(id);
try {
return await this.prisma.implantCatalog.delete({
where: { id: current.id },
select: CATALOG_SELECT,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === 'P2003' || error.code === 'P2014')
) {
throw new ConflictException(MESSAGES.DEVICE.CATALOG_DELETE_CONFLICT);
}
throw error;
}
}
/** /**
* *
*/ */
@ -207,7 +363,13 @@ export class DevicesService {
andConditions.push({ andConditions.push({
OR: [ OR: [
{ {
snCode: { implantModel: {
contains: keyword,
mode: 'insensitive',
},
},
{
implantName: {
contains: keyword, contains: keyword,
mode: 'insensitive', mode: 'insensitive',
}, },
@ -238,6 +400,47 @@ export class DevicesService {
return andConditions.length > 0 ? { AND: andConditions } : {}; return andConditions.length > 0 ? { AND: andConditions } : {};
} }
/**
*
*/
private buildCatalogWhere(keyword?: string): Prisma.ImplantCatalogWhereInput {
const andConditions: Prisma.ImplantCatalogWhereInput[] = [];
const normalizedKeyword = keyword?.trim();
if (normalizedKeyword) {
andConditions.push({
OR: [
{
modelCode: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
{
manufacturer: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
{
name: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
{
notes: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
],
});
}
return andConditions.length > 0 ? { AND: andConditions } : {};
}
/** /**
* *
*/ */
@ -301,6 +504,23 @@ export class DevicesService {
return patient; return patient;
} }
/**
*
*/
private async findWritableCatalog(id: number) {
const catalogId = this.toInt(id, 'id');
const catalog = await this.prisma.implantCatalog.findUnique({
where: { id: catalogId },
select: CATALOG_SELECT,
});
if (!catalog) {
throw new NotFoundException(MESSAGES.DEVICE.CATALOG_NOT_FOUND);
}
return catalog;
}
/** /**
* / * /
*/ */
@ -315,7 +535,7 @@ export class DevicesService {
} }
/** /**
* *
*/ */
private assertAdmin(actor: ActorContext) { private assertAdmin(actor: ActorContext) {
if ( if (
@ -327,29 +547,83 @@ export class DevicesService {
} }
/** /**
* SN * B 访
*/ */
private normalizeSnCode(value: unknown) { private assertCatalogReadable(actor: ActorContext) {
if (typeof value !== 'string') { if (
throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED); 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;
} }
const normalized = value.trim().toUpperCase(); 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) {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
}
const normalized = value.trim();
if (!normalized) { if (!normalized) {
throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED); throw new BadRequestException(`${fieldName} 不能为空`);
} }
return normalized; return normalized;
} }
private normalizeNullableString(value: unknown, fieldName: string) {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
}
const normalized = value.trim();
return normalized || null;
}
/** /**
* *
*/
private normalizePressureLevels(
pressureLevels: unknown[] | undefined,
isPressureAdjustable: boolean,
) {
if (!isPressureAdjustable) {
return [];
}
return normalizePressureLabelList(pressureLevels, 'pressureLevels');
}
/**
*
*/ */
private normalizePressure(value: unknown) { private normalizePressure(value: unknown) {
const parsed = Number(value); try {
if (!Number.isInteger(parsed) || parsed < 0) { return normalizePressureLabel(value, 'currentPressure');
} catch {
throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID); throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID);
} }
return parsed;
} }
/** /**
@ -386,18 +660,4 @@ export class DevicesService {
} }
return actor.hospitalId; return actor.hospitalId;
} }
/**
* SN
*/
private async assertSnCodeUnique(snCode: string, selfId?: number) {
const existing = await this.prisma.device.findUnique({
where: { snCode },
select: { id: true },
});
if (existing && existing.id !== selfId) {
throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE);
}
}
} }

View File

@ -1,22 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { DeviceStatus } from '../../generated/prisma/enums.js'; import { DeviceStatus } from '../../generated/prisma/enums.js';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator'; import { IsEnum, IsInt, IsOptional, Min } from 'class-validator';
/** /**
* DTO * DTO
*/ */
export class CreateDeviceDto { export class CreateDeviceDto {
@ApiProperty({ description: '设备 SN', example: 'TYT-SN-10001' })
@IsString({ message: 'snCode 必须是字符串' })
snCode!: string;
@ApiProperty({ description: '当前压力值', example: 120 })
@Type(() => Number)
@IsInt({ message: 'currentPressure 必须是整数' })
@Min(0, { message: 'currentPressure 必须大于等于 0' })
currentPressure!: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: '设备状态,默认 ACTIVE', description: '设备状态,默认 ACTIVE',
enum: DeviceStatus, enum: DeviceStatus,

View File

@ -0,0 +1,65 @@
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;
}

View File

@ -9,8 +9,9 @@ import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
*/ */
export class DeviceQueryDto { export class DeviceQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: '关键词(支持设备 SN / 患者姓名 / 患者手机号)', description:
example: 'SN-A', '关键词(支持植入物型号 / 植入物名称 / 患者姓名 / 患者手机号)',
example: '脑室',
}) })
@IsOptional() @IsOptional()
@IsString({ message: 'keyword 必须是字符串' }) @IsString({ message: 'keyword 必须是字符串' })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -41,12 +41,9 @@ export class GroupsController {
* *
*/ */
@Post() @Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建小组' }) @ApiOperation({ summary: '创建小组' })
create( create(@CurrentActor() actor: ActorContext, @Body() dto: CreateGroupDto) {
@CurrentActor() actor: ActorContext,
@Body() dto: CreateGroupDto,
) {
return this.groupsService.create(actor, dto); return this.groupsService.create(actor, dto);
} }
@ -59,6 +56,7 @@ export class GroupsController {
Role.HOSPITAL_ADMIN, Role.HOSPITAL_ADMIN,
Role.DIRECTOR, Role.DIRECTOR,
Role.LEADER, Role.LEADER,
Role.DOCTOR,
) )
@ApiOperation({ summary: '查询小组列表' }) @ApiOperation({ summary: '查询小组列表' })
findAll( findAll(
@ -77,6 +75,7 @@ export class GroupsController {
Role.HOSPITAL_ADMIN, Role.HOSPITAL_ADMIN,
Role.DIRECTOR, Role.DIRECTOR,
Role.LEADER, Role.LEADER,
Role.DOCTOR,
) )
@ApiOperation({ summary: '查询小组详情' }) @ApiOperation({ summary: '查询小组详情' })
@ApiParam({ name: 'id', description: '小组 ID' }) @ApiParam({ name: 'id', description: '小组 ID' })
@ -91,12 +90,7 @@ export class GroupsController {
* *
*/ */
@Patch(':id') @Patch(':id')
@Roles( @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '更新小组' }) @ApiOperation({ summary: '更新小组' })
update( update(
@CurrentActor() actor: ActorContext, @CurrentActor() actor: ActorContext,
@ -110,7 +104,7 @@ export class GroupsController {
* *
*/ */
@Delete(':id') @Delete(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '删除小组' }) @ApiOperation({ summary: '删除小组' })
remove( remove(
@CurrentActor() actor: ActorContext, @CurrentActor() actor: ActorContext,

View File

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

View File

@ -26,14 +26,10 @@ export class GroupsService {
) {} ) {}
/** /**
* *
*/ */
async create(actor: ActorContext, dto: CreateGroupDto) { async create(actor: ActorContext, dto: CreateGroupDto) {
this.access.assertRole(actor, [ this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
]);
const departmentId = this.access.toInt( const departmentId = this.access.toInt(
dto.departmentId, dto.departmentId,
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
@ -42,12 +38,6 @@ export class GroupsService {
if (actor.role === Role.HOSPITAL_ADMIN) { if (actor.role === Role.HOSPITAL_ADMIN) {
this.access.assertHospitalScope(actor, department.hospitalId); this.access.assertHospitalScope(actor, department.hospitalId);
} }
if (actor.role === Role.DIRECTOR) {
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
if (actorDepartmentId !== department.id) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
}
return this.prisma.group.create({ return this.prisma.group.create({
data: { data: {
@ -61,7 +51,7 @@ export class GroupsService {
} }
/** /**
* *
*/ */
async findAll(actor: ActorContext, query: OrganizationQueryDto) { async findAll(actor: ActorContext, query: OrganizationQueryDto) {
this.access.assertRole(actor, [ this.access.assertRole(actor, [
@ -69,6 +59,7 @@ export class GroupsService {
Role.HOSPITAL_ADMIN, Role.HOSPITAL_ADMIN,
Role.DIRECTOR, Role.DIRECTOR,
Role.LEADER, Role.LEADER,
Role.DOCTOR,
]); ]);
const paging = this.access.resolvePaging(query); const paging = this.access.resolvePaging(query);
const where: Prisma.GroupWhereInput = {}; const where: Prisma.GroupWhereInput = {};
@ -87,10 +78,12 @@ export class GroupsService {
where.department = { where.department = {
hospitalId: this.access.requireActorHospitalId(actor), hospitalId: this.access.requireActorHospitalId(actor),
}; };
} else if (actor.role === Role.DIRECTOR) { } else if (
actor.role === Role.DIRECTOR ||
actor.role === Role.LEADER ||
actor.role === Role.DOCTOR
) {
where.departmentId = this.access.requireActorDepartmentId(actor); where.departmentId = this.access.requireActorDepartmentId(actor);
} else if (actor.role === Role.LEADER) {
where.id = this.access.requireActorGroupId(actor);
} else if (query.hospitalId != null) { } else if (query.hospitalId != null) {
where.department = { where.department = {
hospitalId: this.access.toInt( hospitalId: this.access.toInt(
@ -118,7 +111,7 @@ export class GroupsService {
} }
/** /**
* *
*/ */
async findOne(actor: ActorContext, id: number) { async findOne(actor: ActorContext, id: number) {
this.access.assertRole(actor, [ this.access.assertRole(actor, [
@ -126,6 +119,7 @@ export class GroupsService {
Role.HOSPITAL_ADMIN, Role.HOSPITAL_ADMIN,
Role.DIRECTOR, Role.DIRECTOR,
Role.LEADER, Role.LEADER,
Role.DOCTOR,
]); ]);
const groupId = this.access.toInt(id, MESSAGES.ORG.GROUP_ID_REQUIRED); const groupId = this.access.toInt(id, MESSAGES.ORG.GROUP_ID_REQUIRED);
const group = await this.prisma.group.findUnique({ const group = await this.prisma.group.findUnique({
@ -140,24 +134,21 @@ export class GroupsService {
} }
if (actor.role === Role.HOSPITAL_ADMIN) { if (actor.role === Role.HOSPITAL_ADMIN) {
this.access.assertHospitalScope(actor, group.department.hospital.id); this.access.assertHospitalScope(actor, group.department.hospital.id);
} } else if (
if (actor.role === Role.DIRECTOR) { actor.role === Role.DIRECTOR ||
actor.role === Role.LEADER ||
actor.role === Role.DOCTOR
) {
const actorDepartmentId = this.access.requireActorDepartmentId(actor); const actorDepartmentId = this.access.requireActorDepartmentId(actor);
if (group.department.id !== actorDepartmentId) { if (group.department.id !== actorDepartmentId) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
}
}
if (actor.role === Role.LEADER) {
const actorGroupId = this.access.requireActorGroupId(actor);
if (group.id !== actorGroupId) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
} }
} }
return group; return group;
} }
/** /**
* *
*/ */
async update(actor: ActorContext, id: number, dto: UpdateGroupDto) { async update(actor: ActorContext, id: number, dto: UpdateGroupDto) {
const current = await this.findOne(actor, id); const current = await this.findOne(actor, id);
@ -181,14 +172,10 @@ export class GroupsService {
} }
/** /**
* *
*/ */
async remove(actor: ActorContext, id: number) { async remove(actor: ActorContext, id: number) {
this.access.assertRole(actor, [ this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
]);
const current = await this.findOne(actor, id); const current = await this.findOne(actor, id);
// 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。 // 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。

View File

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

View File

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

View File

@ -1,16 +1,23 @@
import 'dotenv/config'; import 'dotenv/config';
import { mkdirSync } from 'node:fs';
import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module.js'; import { AppModule } from './app.module.js';
import { HttpExceptionFilter } from './common/http-exception.filter.js'; import { HttpExceptionFilter } from './common/http-exception.filter.js';
import { MESSAGES } from './common/messages.js'; import { MESSAGES } from './common/messages.js';
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js'; import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
import { resolveUploadRootDir } from './uploads/upload-path.util.js';
async function bootstrap() { async function bootstrap() {
// 创建应用实例并加载核心模块。 // 创建应用实例并加载核心模块。
const app = await NestFactory.create(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableCors(); app.enableCors();
mkdirSync(resolveUploadRootDir(), { recursive: true });
app.useStaticAssets(resolveUploadRootDir(), {
prefix: '/uploads/',
});
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。 // 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({

View File

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

View File

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

View File

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

View File

@ -6,18 +6,134 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '../../generated/prisma/client.js'; import { Prisma } from '../../generated/prisma/client.js';
import { Role } from '../../generated/prisma/enums.js'; import { DeviceStatus, Role } from '../../generated/prisma/enums.js';
import { PrismaService } from '../../prisma.service.js'; import { PrismaService } from '../../prisma.service.js';
import type { ActorContext } from '../../common/actor-context.js'; import type { ActorContext } from '../../common/actor-context.js';
import { MESSAGES } from '../../common/messages.js'; import { MESSAGES } from '../../common/messages.js';
import { normalizePressureLabel } from '../../common/pressure-level.util.js';
import { CreatePatientDto } from '../dto/create-patient.dto.js'; import { CreatePatientDto } from '../dto/create-patient.dto.js';
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
import { UpdatePatientDto } from '../dto/update-patient.dto.js'; import { UpdatePatientDto } from '../dto/update-patient.dto.js';
import { normalizePatientIdCard } from '../patient-id-card.util.js'; import { normalizePatientIdCard } from '../patient-id-card.util.js';
type PrismaExecutor = Prisma.TransactionClient | PrismaService;
const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]; const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER];
const IMPLANT_CATALOG_SELECT = {
id: true,
modelCode: true,
manufacturer: true,
name: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
} as const;
const PATIENT_LIST_INCLUDE = {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
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() @Injectable()
export class BPatientsService { export class BPatientsService {
@ -30,15 +146,27 @@ export class BPatientsService {
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId); const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
const where = this.buildVisiblePatientWhere(actor, hospitalId); const where = this.buildVisiblePatientWhere(actor, hospitalId);
return this.prisma.patient.findMany({ const patients = await this.prisma.patient.findMany({
where, where,
include: { include: PATIENT_LIST_INCLUDE,
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
}); });
return patients.map((patient) => {
const { _count, surgeries, ...rest } = patient;
return {
...rest,
shuntSurgeryCount: _count.surgeries,
latestSurgery: surgeries[0] ?? null,
activeDeviceCount: patient.devices.filter(
(device) =>
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
).length,
abandonedDeviceCount: patient.devices.filter(
(device) => device.isAbandoned,
).length,
};
});
} }
/** /**
@ -90,25 +218,78 @@ export class BPatientsService {
} }
/** /**
* *
*/ */
async createPatient(actor: ActorContext, dto: CreatePatientDto) { async createPatient(actor: ActorContext, dto: CreatePatientDto) {
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId); const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
return this.prisma.patient.create({ return this.prisma.$transaction(async (tx) => {
const patient = await tx.patient.create({
data: { data: {
name: this.normalizeRequiredString(dto.name, 'name'), name: this.normalizeRequiredString(dto.name, 'name'),
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), phone: this.normalizePhone(dto.phone),
// 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。 // 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。
idCard: this.normalizeIdCard(dto.idCard), idCard: this.normalizeIdCard(dto.idCard),
hospitalId: doctor.hospitalId!, hospitalId: doctor.hospitalId!,
doctorId: doctor.id, doctorId: doctor.id,
}, },
include: { });
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } }, if (dto.initialSurgery) {
devices: true, await this.createPatientSurgeryRecord(
}, tx,
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;
}); });
} }
@ -118,20 +299,38 @@ export class BPatientsService {
async findPatientById(actor: ActorContext, id: number) { async findPatientById(actor: ActorContext, id: number) {
const patient = await this.findPatientWithScope(id); const patient = await this.findPatientWithScope(id);
this.assertPatientScope(actor, patient); this.assertPatientScope(actor, patient);
return patient; return this.decoratePatientDetail(patient);
} }
/** /**
* *
*/ */
async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) { async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) {
const patient = await this.findPatientWithScope(id); const patient = await this.findPatientWithScope(id);
this.assertPatientScope(actor, patient); this.assertPatientScope(actor, patient);
if (dto.initialSurgery !== undefined) {
throw new BadRequestException(
MESSAGES.PATIENT.SURGERY_UPDATE_NOT_SUPPORTED,
);
}
const data: Prisma.PatientUpdateInput = {}; const data: Prisma.PatientUpdateInput = {};
if (dto.name !== undefined) { if (dto.name !== undefined) {
data.name = this.normalizeRequiredString(dto.name, 'name'); data.name = this.normalizeRequiredString(dto.name, 'name');
} }
if (dto.inpatientNo !== undefined) {
data.inpatientNo = this.normalizeNullableString(
dto.inpatientNo,
'inpatientNo',
);
}
if (dto.projectName !== undefined) {
data.projectName = this.normalizeNullableString(
dto.projectName,
'projectName',
);
}
if (dto.phone !== undefined) { if (dto.phone !== undefined) {
data.phone = this.normalizePhone(dto.phone); data.phone = this.normalizePhone(dto.phone);
} }
@ -145,15 +344,13 @@ export class BPatientsService {
data.hospital = { connect: { id: doctor.hospitalId! } }; data.hospital = { connect: { id: doctor.hospitalId! } };
} }
return this.prisma.patient.update({ const updated = await this.prisma.patient.update({
where: { id: patient.id }, where: { id: patient.id },
data, data,
include: {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
}); });
const detail = await this.loadPatientDetail(this.prisma, updated.id);
return this.decoratePatientDetail(detail);
} }
/** /**
@ -164,14 +361,11 @@ export class BPatientsService {
this.assertPatientScope(actor, patient); this.assertPatientScope(actor, patient);
try { try {
return await this.prisma.patient.delete({ const deleted = await this.prisma.patient.delete({
where: { id: patient.id }, where: { id: patient.id },
include: { include: PATIENT_DETAIL_INCLUDE,
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
}); });
return this.decoratePatientDetail(deleted);
} catch (error) { } catch (error) {
if ( if (
error instanceof Prisma.PrismaClientKnownRequestError && error instanceof Prisma.PrismaClientKnownRequestError &&
@ -192,22 +386,16 @@ export class BPatientsService {
throw new BadRequestException('id 必须为整数'); throw new BadRequestException('id 必须为整数');
} }
const patient = await this.prisma.patient.findUnique({ return this.loadPatientDetail(this.prisma, patientId);
}
/**
*
*/
private async loadPatientDetail(prisma: PrismaExecutor, patientId: number) {
const patient = await prisma.patient.findUnique({
where: { id: patientId }, where: { id: patientId },
include: { include: PATIENT_DETAIL_INCLUDE,
hospital: { select: { id: true, name: true } },
doctor: {
select: {
id: true,
name: true,
role: true,
hospitalId: true,
departmentId: true,
groupId: true,
},
},
devices: true,
},
}); });
if (!patient) { if (!patient) {
@ -272,6 +460,7 @@ export class BPatientsService {
where: { id: normalizedDoctorId }, where: { id: normalizedDoctorId },
select: { select: {
id: true, id: true,
name: true,
role: true, role: true,
hospitalId: true, hospitalId: true,
departmentId: true, departmentId: true,
@ -380,6 +569,299 @@ export class BPatientsService {
return actor.hospitalId; 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) { private normalizeRequiredString(value: unknown, fieldName: string) {
if (typeof value !== 'string') { if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`); throw new BadRequestException(`${fieldName} 必须是字符串`);
@ -391,6 +873,14 @@ export class BPatientsService {
return trimmed; return trimmed;
} }
private normalizeNullableString(value: unknown, fieldName: string) {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
}
const trimmed = value.trim();
return trimmed || null;
}
private normalizePhone(phone: unknown) { private normalizePhone(phone: unknown) {
const normalized = this.normalizeRequiredString(phone, 'phone'); const normalized = this.normalizeRequiredString(phone, 'phone');
if (!/^1\d{10}$/.test(normalized)) { if (!/^1\d{10}$/.test(normalized)) {
@ -406,4 +896,62 @@ export class BPatientsService {
const normalized = this.normalizeRequiredString(value, 'idCard'); const normalized = this.normalizeRequiredString(value, 'idCard');
return normalizePatientIdCard(normalized); return normalizePatientIdCard(normalized);
} }
private normalizeIsoDate(value: unknown, fieldName: string) {
const normalized = this.normalizeRequiredString(value, fieldName);
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException(`${fieldName} 必须是合法日期`);
}
return parsed;
}
private normalizeNonNegativeInteger(value: unknown, fieldName: string) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new BadRequestException(`${fieldName} 必须是大于等于 0 的整数`);
}
return parsed;
}
private 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;
}
} }

View File

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

View File

@ -0,0 +1,109 @@
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[];
}

View File

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

View File

@ -0,0 +1,85 @@
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;
}

View File

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

View File

@ -1,5 +1,10 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../../common/actor-context.js'; import type { ActorContext } from '../../common/actor-context.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js'; import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { AccessTokenGuard } from '../../auth/access-token.guard.js'; import { AccessTokenGuard } from '../../auth/access-token.guard.js';
@ -11,6 +16,8 @@ import { PublishTaskDto } from '../dto/publish-task.dto.js';
import { AcceptTaskDto } from '../dto/accept-task.dto.js'; import { AcceptTaskDto } from '../dto/accept-task.dto.js';
import { CompleteTaskDto } from '../dto/complete-task.dto.js'; import { CompleteTaskDto } from '../dto/complete-task.dto.js';
import { CancelTaskDto } from '../dto/cancel-task.dto.js'; import { CancelTaskDto } from '../dto/cancel-task.dto.js';
import { TaskRecordQueryDto } from '../dto/task-record-query.dto.js';
import { AssignableEngineerQueryDto } from '../dto/assignable-engineer-query.dto.js';
/** /**
* B * B
@ -23,21 +30,78 @@ export class BTasksController {
constructor(private readonly taskService: TaskService) {} constructor(private readonly taskService: TaskService) {}
/** /**
* // *
*/
@Get('engineers')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '查询可选接收工程师列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
description: '系统管理员可按医院筛选',
})
findAssignableEngineers(
@CurrentActor() actor: ActorContext,
@Query() query: AssignableEngineerQueryDto,
) {
return this.taskService.findAssignableEngineers(actor, query.hospitalId);
}
/**
*
*/
@Get()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
Role.ENGINEER,
)
@ApiOperation({ summary: '查询调压记录列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
description: '系统管理员可按医院筛选',
})
findRecords(
@CurrentActor() actor: ActorContext,
@Query() query: TaskRecordQueryDto,
) {
return this.taskService.findTaskRecords(actor, query);
}
/**
* ////
*/ */
@Post('publish') @Post('publish')
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER) @Roles(
@ApiOperation({ summary: '发布任务DOCTOR/DIRECTOR/LEADER' }) Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({
summary: '发布任务SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER',
})
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) { publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
return this.taskService.publishTask(actor, dto); return this.taskService.publishTask(actor, dto);
} }
/** /**
* *
*/ */
@Post('accept') @Post('accept')
@Roles(Role.ENGINEER) @Roles(Role.ENGINEER)
@ApiOperation({ summary: '接收任务ENGINEER' }) @ApiOperation({ summary: '接收任务(已停用' })
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) { accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
return this.taskService.acceptTask(actor, dto); return this.taskService.acceptTask(actor, dto);
} }
@ -53,11 +117,19 @@ export class BTasksController {
} }
/** /**
* // * ////
*/ */
@Post('cancel') @Post('cancel')
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER) @Roles(
@ApiOperation({ summary: '取消任务DOCTOR/DIRECTOR/LEADER' }) Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({
summary: '取消任务SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER',
})
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) { cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
return this.taskService.cancelTask(actor, dto); return this.taskService.cancelTask(actor, dto);
} }

View File

@ -0,0 +1,20 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min } from 'class-validator';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
/**
* DTO
*/
export class AssignableEngineerQueryDto {
@ApiPropertyOptional({
description: '医院 ID仅系统管理员可选传',
example: 1,
})
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: number;
}

View File

@ -1,11 +1,10 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import { import {
ArrayMinSize, ArrayMinSize,
IsArray, IsArray,
IsInt, IsInt,
IsOptional, IsString,
Min, Min,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
@ -20,23 +19,20 @@ export class PublishTaskItemDto {
@Min(1, { message: 'deviceId 必须大于 0' }) @Min(1, { message: 'deviceId 必须大于 0' })
deviceId!: number; deviceId!: number;
@ApiProperty({ description: '目标压力值', example: 120 }) @ApiProperty({ description: '目标挡位标签', example: '1.5' })
@Type(() => Number) @IsString({ message: 'targetPressure 必须是字符串' })
@IsInt({ message: 'targetPressure 必须是整数' }) targetPressure!: string;
targetPressure!: number;
} }
/** /**
* DTO * DTO
*/ */
export class PublishTaskDto { export class PublishTaskDto {
@ApiPropertyOptional({ description: '指定工程师 ID可选', example: 2 }) @ApiProperty({ description: '接收工程师 ID', example: 2 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number) @Type(() => Number)
@IsInt({ message: 'engineerId 必须是整数' }) @IsInt({ message: 'engineerId 必须是整数' })
@Min(1, { message: 'engineerId 必须大于 0' }) @Min(1, { message: 'engineerId 必须大于 0' })
engineerId?: number; engineerId!: number;
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' }) @ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
@IsArray({ message: 'items 必须是数组' }) @IsArray({ message: 'items 必须是数组' })

View File

@ -0,0 +1,63 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import { TaskStatus } from '../../generated/prisma/enums.js';
/**
* DTO
*/
export class TaskRecordQueryDto {
@ApiPropertyOptional({
description: '关键词(支持患者姓名/住院号/手机号/植入物名称/植入物型号)',
example: '张三',
})
@IsOptional()
@IsString({ message: 'keyword 必须是字符串' })
keyword?: string;
@ApiPropertyOptional({
description: '任务状态',
enum: TaskStatus,
example: TaskStatus.PENDING,
})
@IsOptional()
@IsEnum(TaskStatus, { message: 'status 枚举值不合法' })
status?: TaskStatus;
@ApiPropertyOptional({
description: '医院 ID仅系统管理员可选传',
example: 1,
})
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: number;
@ApiPropertyOptional({
description: '页码(默认 1',
example: 1,
default: 1,
})
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'page 必须是整数' })
@Min(1, { message: 'page 最小为 1' })
page?: number = 1;
@ApiPropertyOptional({
description: '每页数量(默认 20最大 100',
example: 20,
default: 20,
})
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'pageSize 必须是整数' })
@Min(1, { message: 'pageSize 最小为 1' })
@Max(100, { message: 'pageSize 最大为 100' })
pageSize?: number = 20;
}

View File

@ -6,6 +6,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma } from '../generated/prisma/client.js';
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js'; import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
import { PrismaService } from '../prisma.service.js'; import { PrismaService } from '../prisma.service.js';
import type { ActorContext } from '../common/actor-context.js'; import type { ActorContext } from '../common/actor-context.js';
@ -13,7 +14,9 @@ import { PublishTaskDto } from './dto/publish-task.dto.js';
import { AcceptTaskDto } from './dto/accept-task.dto.js'; import { AcceptTaskDto } from './dto/accept-task.dto.js';
import { CompleteTaskDto } from './dto/complete-task.dto.js'; import { CompleteTaskDto } from './dto/complete-task.dto.js';
import { CancelTaskDto } from './dto/cancel-task.dto.js'; import { CancelTaskDto } from './dto/cancel-task.dto.js';
import { TaskRecordQueryDto } from './dto/task-record-query.dto.js';
import { MESSAGES } from '../common/messages.js'; import { MESSAGES } from '../common/messages.js';
import { normalizePressureLabel } from '../common/pressure-level.util.js';
/** /**
* *
@ -26,11 +29,162 @@ export class TaskService {
) {} ) {}
/** /**
* // PENDING *
*/
async findAssignableEngineers(
actor: ActorContext,
requestedHospitalId?: number,
) {
this.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
]);
const hospitalId = this.resolveAssignableHospitalId(
actor,
requestedHospitalId,
);
return this.prisma.user.findMany({
where: {
role: Role.ENGINEER,
hospitalId,
},
select: {
id: true,
name: true,
phone: true,
hospitalId: true,
},
orderBy: { id: 'desc' },
});
}
/**
*
*/
async findTaskRecords(actor: ActorContext, query: TaskRecordQueryDto) {
this.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
Role.ENGINEER,
]);
const hospitalId = this.resolveListHospitalId(actor, query.hospitalId);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = this.buildTaskRecordWhere(query, hospitalId);
const [total, items] = await Promise.all([
this.prisma.taskItem.count({ where }),
this.prisma.taskItem.findMany({
where,
skip,
take: pageSize,
orderBy: { id: 'desc' },
select: {
id: true,
oldPressure: true,
targetPressure: true,
task: {
select: {
id: true,
status: true,
createdAt: true,
hospital: {
select: {
id: true,
name: true,
},
},
creator: {
select: {
id: true,
name: true,
role: true,
},
},
engineer: {
select: {
id: true,
name: true,
role: true,
},
},
},
},
device: {
select: {
id: true,
currentPressure: true,
implantModel: true,
implantManufacturer: true,
implantName: true,
patient: {
select: {
id: true,
name: true,
inpatientNo: true,
phone: true,
},
},
surgery: {
select: {
id: true,
surgeryName: true,
surgeryDate: true,
},
},
},
},
},
}),
]);
return {
list: items.map((item) => ({
id: item.id,
oldPressure: item.oldPressure,
targetPressure: item.targetPressure,
currentPressure: item.device.currentPressure,
taskId: item.task.id,
status: item.task.status,
createdAt: item.task.createdAt,
hospital: item.task.hospital,
creator: item.task.creator,
engineer: item.task.engineer,
patient: item.device.patient,
surgery: item.device.surgery,
device: {
id: item.device.id,
implantModel: item.device.implantModel,
implantManufacturer: item.device.implantManufacturer,
implantName: item.device.implantName,
},
})),
total,
page,
pageSize,
};
}
/**
*
*/ */
async publishTask(actor: ActorContext, dto: PublishTaskDto) { async publishTask(actor: ActorContext, dto: PublishTaskDto) {
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]); this.assertRole(actor, [
const hospitalId = this.requireHospitalId(actor); Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
]);
if (!Array.isArray(dto.items) || dto.items.length === 0) { if (!Array.isArray(dto.items) || dto.items.length === 0) {
throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED); throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED);
@ -42,30 +196,49 @@ export class TaskService {
if (!Number.isInteger(item.deviceId)) { if (!Number.isInteger(item.deviceId)) {
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`); throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
} }
if (!Number.isInteger(item.targetPressure)) {
throw new BadRequestException(
`targetPressure 非法: ${item.targetPressure}`,
);
}
return item.deviceId; return item.deviceId;
}), }),
), ),
); );
const scopedHospitalId = this.resolveScopedHospitalId(actor);
const devices = await this.prisma.device.findMany({ const devices = await this.prisma.device.findMany({
where: { where: {
id: { in: deviceIds }, id: { in: deviceIds },
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
patient: { hospitalId }, isAbandoned: false,
isPressureAdjustable: true,
patient: scopedHospitalId
? {
hospitalId: scopedHospitalId,
}
: undefined,
},
select: {
id: true,
currentPressure: true,
patient: {
select: {
hospitalId: true,
},
},
implantCatalog: {
select: {
pressureLevels: true,
},
},
}, },
select: { id: true, currentPressure: true },
}); });
if (devices.length !== deviceIds.length) { if (devices.length !== deviceIds.length) {
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND); throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
} }
if (dto.engineerId != null) { const hospitalId = this.resolveTaskHospitalId(
actor,
devices.map((device) => device.patient.hospitalId),
);
const engineer = await this.prisma.user.findFirst({ const engineer = await this.prisma.user.findFirst({
where: { where: {
id: dto.engineerId, id: dto.engineerId,
@ -77,22 +250,44 @@ export class TaskService {
if (!engineer) { if (!engineer) {
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID); throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
} }
}
const pressureByDeviceId = new Map( const pressureByDeviceId = new Map(
devices.map((device) => [device.id, device.currentPressure] as const), devices.map((device) => [device.id, device.currentPressure] as const),
); );
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({ const task = await this.prisma.task.create({
data: { data: {
status: TaskStatus.PENDING, status: TaskStatus.ACCEPTED,
creatorId: actor.id, creatorId: actor.id,
engineerId: dto.engineerId ?? null, engineerId: engineer.id,
hospitalId, hospitalId,
items: { items: {
create: dto.items.map((item) => ({ create: dto.items.map((item) => ({
deviceId: item.deviceId, deviceId: item.deviceId,
oldPressure: pressureByDeviceId.get(item.deviceId) ?? 0, oldPressure: pressureByDeviceId.get(item.deviceId) ?? '0',
targetPressure: item.targetPressure, targetPressure: item.targetPressure,
})), })),
}, },
@ -111,68 +306,10 @@ export class TaskService {
} }
/** /**
* PENDING ACCEPTED *
*/ */
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) { async acceptTask(_actor: ActorContext, _dto: AcceptTaskDto) {
this.assertRole(actor, [Role.ENGINEER]); throw new ForbiddenException(MESSAGES.TASK.ACCEPT_DISABLED);
const hospitalId = this.requireHospitalId(actor);
const task = await this.prisma.task.findFirst({
where: {
id: dto.taskId,
hospitalId,
},
select: {
id: true,
status: true,
hospitalId: true,
engineerId: true,
},
});
if (!task) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (task.status !== TaskStatus.PENDING) {
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
}
if (task.engineerId != null && task.engineerId !== actor.id) {
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
}
const accepted = await this.prisma.task.updateMany({
where: {
id: task.id,
hospitalId,
status: TaskStatus.PENDING,
OR: [{ engineerId: null }, { engineerId: actor.id }],
},
data: {
status: TaskStatus.ACCEPTED,
engineerId: actor.id,
},
});
if (accepted.count !== 1) {
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
}
const updatedTask = await this.prisma.task.findUnique({
where: { id: task.id },
include: { items: true },
});
if (!updatedTask) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
await this.eventEmitter.emitAsync('task.accepted', {
taskId: updatedTask.id,
hospitalId: updatedTask.hospitalId,
actorId: actor.id,
status: updatedTask.status,
});
return updatedTask;
} }
/** /**
@ -235,13 +372,19 @@ export class TaskService {
* PENDING/ACCEPTED * PENDING/ACCEPTED
*/ */
async cancelTask(actor: ActorContext, dto: CancelTaskDto) { async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]); this.assertRole(actor, [
const hospitalId = this.requireHospitalId(actor); Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
]);
const scopedHospitalId = this.resolveScopedHospitalId(actor);
const task = await this.prisma.task.findFirst({ const task = await this.prisma.task.findFirst({
where: { where: {
id: dto.taskId, id: dto.taskId,
hospitalId, hospitalId: scopedHospitalId ?? undefined,
}, },
select: { select: {
id: true, id: true,
@ -292,7 +435,154 @@ export class TaskService {
} }
/** /**
* hospitalIdB *
*/
private resolveScopedHospitalId(actor: ActorContext): number | null {
if (actor.role === Role.SYSTEM_ADMIN) {
return actor.hospitalId ?? null;
}
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
/**
*
*/
private resolveTaskHospitalId(
actor: ActorContext,
hospitalIds: number[],
): number {
const uniqueHospitalIds = Array.from(
new Set(hospitalIds.filter((hospitalId) => Number.isInteger(hospitalId))),
);
if (uniqueHospitalIds.length !== 1) {
throw new BadRequestException(MESSAGES.TASK.DEVICE_MULTI_HOSPITAL);
}
const [hospitalId] = uniqueHospitalIds;
if (actor.hospitalId && actor.hospitalId !== hospitalId) {
throw new ForbiddenException(MESSAGES.TASK.DEVICE_NOT_FOUND);
}
return hospitalId;
}
/**
*
*/
private resolveAssignableHospitalId(
actor: ActorContext,
requestedHospitalId?: number,
) {
if (actor.role === Role.SYSTEM_ADMIN) {
if (requestedHospitalId !== undefined) {
return requestedHospitalId;
}
return this.requireHospitalId(actor);
}
return this.requireHospitalId(actor);
}
/**
*
*/
private resolveListHospitalId(
actor: ActorContext,
requestedHospitalId?: number,
) {
if (actor.role === Role.SYSTEM_ADMIN) {
return requestedHospitalId ?? actor.hospitalId ?? undefined;
}
return this.requireHospitalId(actor);
}
/**
*
*/
private buildTaskRecordWhere(
query: TaskRecordQueryDto,
hospitalId?: number,
): Prisma.TaskItemWhereInput {
const keyword = query.keyword?.trim();
const where: Prisma.TaskItemWhereInput = {
task: {
hospitalId,
status: query.status,
},
};
if (!keyword) {
return where;
}
where.OR = [
{
device: {
patient: {
name: {
contains: keyword,
mode: 'insensitive',
},
},
},
},
{
device: {
patient: {
inpatientNo: {
contains: keyword,
mode: 'insensitive',
},
},
},
},
{
device: {
patient: {
phone: {
contains: keyword,
mode: 'insensitive',
},
},
},
},
{
device: {
implantName: {
contains: keyword,
mode: 'insensitive',
},
},
},
{
device: {
implantModel: {
contains: keyword,
mode: 'insensitive',
},
},
},
];
return where;
}
/**
*
*/
private normalizeTargetPressure(value: unknown) {
return normalizePressureLabel(value, 'targetPressure');
}
/**
*
*/ */
private requireHospitalId(actor: ActorContext): number { private requireHospitalId(actor: ActorContext): number {
if (!actor.hospitalId) { if (!actor.hospitalId) {

View File

@ -0,0 +1,133 @@
import {
BadRequestException,
Body,
Controller,
Get,
ParseIntPipe,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { Roles } from '../../auth/roles.decorator.js';
import { RolesGuard } from '../../auth/roles.guard.js';
import type { ActorContext } from '../../common/actor-context.js';
import { Role } from '../../generated/prisma/enums.js';
import { MESSAGES } from '../../common/messages.js';
import { UploadAssetQueryDto } from '../dto/upload-asset-query.dto.js';
import { ensureUploadDirectories, resolveUploadTempDir } from '../upload-path.util.js';
import { UploadsService } from '../uploads.service.js';
import { diskStorage } from 'multer';
import { extname } from 'node:path';
import { randomUUID } from 'node:crypto';
const MAX_UPLOAD_SIZE = 1024 * 1024 * 200;
function isAllowedMimeType(mimeType: string) {
return (
mimeType.startsWith('image/') ||
mimeType.startsWith('video/') ||
[
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
].includes(mimeType)
);
}
@ApiTags('上传资产(B端)')
@ApiBearerAuth('bearer')
@Controller('b/uploads')
@UseGuards(AccessTokenGuard, RolesGuard)
export class BUploadsController {
constructor(private readonly uploadsService: UploadsService) {}
/**
* //
*/
@Post()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (_req, _file, cb) => {
ensureUploadDirectories();
cb(null, resolveUploadTempDir());
},
filename: (_req, file, cb) => {
cb(null, `${randomUUID()}${extname(file.originalname)}`);
},
}),
limits: { fileSize: MAX_UPLOAD_SIZE },
fileFilter: (_req, file, cb) => {
if (!isAllowedMimeType(file.mimetype)) {
cb(
new BadRequestException(MESSAGES.UPLOAD.UNSUPPORTED_FILE_TYPE),
false,
);
return;
}
cb(null, true);
},
}),
)
@ApiOperation({ summary: '上传图片/视频/文件' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
},
hospitalId: {
type: 'integer',
},
},
required: ['file'],
},
})
create(
@CurrentActor() actor: ActorContext,
@UploadedFile() file?: Express.Multer.File,
@Body('hospitalId') hospitalId?: string,
) {
const requestedHospitalId =
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
return this.uploadsService.createUpload(actor, file, requestedHospitalId);
}
/**
* //
*/
@Get()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询上传资产列表' })
findAll(
@CurrentActor() actor: ActorContext,
@Query() query: UploadAssetQueryDto,
) {
return this.uploadsService.findAll(actor, query);
}
}

View File

@ -0,0 +1,63 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import { UploadAssetType } from '../../generated/prisma/enums.js';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
/**
* DTO/
*/
export class UploadAssetQueryDto {
@ApiPropertyOptional({
description: '关键词(按原始文件名模糊匹配)',
example: 'ct',
})
@IsOptional()
@IsString({ message: 'keyword 必须是字符串' })
keyword?: string;
@ApiPropertyOptional({
description: '资产类型',
enum: UploadAssetType,
example: UploadAssetType.IMAGE,
})
@IsOptional()
@IsEnum(UploadAssetType, { message: 'type 枚举值不合法' })
type?: UploadAssetType;
@ApiPropertyOptional({ description: '医院 IDSYSTEM_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;
}

View File

@ -0,0 +1,18 @@
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
/**
*
*/
export function resolveUploadRootDir() {
return join(process.cwd(), 'storage', 'uploads');
}
export function resolveUploadTempDir() {
return join(process.cwd(), 'storage', 'tmp-uploads');
}
export function ensureUploadDirectories() {
mkdirSync(resolveUploadRootDir(), { recursive: true });
mkdirSync(resolveUploadTempDir(), { recursive: true });
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma.module.js';
import { BUploadsController } from './b-uploads/b-uploads.controller.js';
import { UploadsService } from './uploads.service.js';
@Module({
imports: [PrismaModule],
controllers: [BUploadsController],
providers: [UploadsService],
exports: [UploadsService],
})
export class UploadsModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UploadsService } from './uploads.service';
describe('UploadsService', () => {
let service: UploadsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UploadsService],
}).compile();
service = module.get<UploadsService>(UploadsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,383 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { spawn } from 'node:child_process';
import { access, mkdir, rename, stat, unlink } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { randomUUID } from 'node:crypto';
import ffmpegPath from 'ffmpeg-static';
import sharp from 'sharp';
import { Prisma } from '../generated/prisma/client.js';
import { Role, UploadAssetType } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import { UploadAssetQueryDto } from './dto/upload-asset-query.dto.js';
import {
ensureUploadDirectories,
resolveUploadRootDir,
resolveUploadTempDir,
} from './upload-path.util.js';
@Injectable()
export class UploadsService {
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
async createUpload(
actor: ActorContext,
file: Express.Multer.File | undefined,
requestedHospitalId?: number,
) {
if (!file) {
throw new BadRequestException(MESSAGES.UPLOAD.FILE_REQUIRED);
}
const hospitalId = await this.resolveHospitalScope(actor, requestedHospitalId);
const type = this.detectAssetType(file.mimetype);
ensureUploadDirectories();
const prepared = await this.prepareStoredFile(file, type);
const date = new Date();
const relativeDir = join(
`${date.getFullYear()}`,
`${date.getMonth() + 1}`.padStart(2, '0'),
`${date.getDate()}`.padStart(2, '0'),
);
const absoluteDir = join(resolveUploadRootDir(), relativeDir);
await mkdir(absoluteDir, { recursive: true });
const finalFileName = await this.buildFinalFileName(
absoluteDir,
file.originalname,
prepared.outputExtension,
date,
);
const relativePath = join(relativeDir, finalFileName);
const absolutePath = join(resolveUploadRootDir(), relativePath);
try {
await rename(prepared.tempPath, absolutePath);
if (prepared.tempPath !== file.path) {
await this.safeUnlink(file.path);
}
} catch (error) {
await this.safeUnlink(file.path);
await this.safeUnlink(prepared.tempPath);
throw error;
}
const url = `/${relativePath.replace(/\\/g, '/')}`.replace(/^\/+/, '/uploads/');
try {
return await this.prisma.uploadAsset.create({
data: {
hospitalId,
creatorId: actor.id,
type,
originalName: file.originalname,
fileName: finalFileName,
storagePath: relativePath.replace(/\\/g, '/'),
url,
mimeType: prepared.mimeType,
fileSize: prepared.fileSize,
},
include: {
hospital: { select: { id: true, name: true } },
creator: { select: { id: true, name: true, role: true } },
},
});
} catch (error) {
await this.safeUnlink(absolutePath);
throw error;
}
}
/**
*
*/
async findAll(actor: ActorContext, query: UploadAssetQueryDto) {
const page = query.page && query.page > 0 ? query.page : 1;
const pageSize =
query.pageSize && query.pageSize > 0 && query.pageSize <= 100
? query.pageSize
: 20;
const where: Prisma.UploadAssetWhereInput = {};
const scopedHospitalId = this.resolveReadableHospitalScope(actor, query.hospitalId);
if (scopedHospitalId != null) {
where.hospitalId = scopedHospitalId;
}
if (query.type) {
where.type = query.type;
}
if (query.keyword?.trim()) {
where.originalName = {
contains: query.keyword.trim(),
mode: 'insensitive',
};
}
const [total, list] = await this.prisma.$transaction([
this.prisma.uploadAsset.count({ where }),
this.prisma.uploadAsset.findMany({
where,
include: {
hospital: { select: { id: true, name: true } },
creator: { select: { id: true, name: true, role: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return { total, page, pageSize, list };
}
private detectAssetType(mimeType: string) {
if (mimeType.startsWith('image/')) {
return UploadAssetType.IMAGE;
}
if (mimeType.startsWith('video/')) {
return UploadAssetType.VIDEO;
}
return UploadAssetType.FILE;
}
private async resolveHospitalScope(
actor: ActorContext,
requestedHospitalId?: number,
) {
if (actor.role === Role.SYSTEM_ADMIN) {
if (
requestedHospitalId == null ||
!Number.isInteger(requestedHospitalId) ||
requestedHospitalId <= 0
) {
throw new BadRequestException(
MESSAGES.UPLOAD.SYSTEM_ADMIN_HOSPITAL_REQUIRED,
);
}
const hospital = await this.prisma.hospital.findUnique({
where: { id: requestedHospitalId },
select: { id: true },
});
if (!hospital) {
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
}
return hospital.id;
}
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.UPLOAD.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
private async prepareStoredFile(
file: Express.Multer.File,
type: UploadAssetType,
) {
const baseName = randomUUID();
if (type === UploadAssetType.IMAGE) {
const tempPath = join(resolveUploadTempDir(), `${baseName}.webp`);
try {
await sharp(file.path)
.rotate()
.resize({
width: 2560,
height: 2560,
fit: 'inside',
withoutEnlargement: true,
})
.webp({
quality: 82,
effort: 4,
})
.toFile(tempPath);
} catch {
await this.safeUnlink(tempPath);
throw new BadRequestException(MESSAGES.UPLOAD.INVALID_IMAGE_FILE);
}
const fileInfo = await stat(tempPath);
return {
tempPath,
outputExtension: '.webp',
mimeType: 'image/webp',
fileSize: fileInfo.size,
};
}
if (type === UploadAssetType.VIDEO) {
const tempPath = join(resolveUploadTempDir(), `${baseName}.mp4`);
try {
await this.compressVideo(file.path, tempPath);
} catch (error) {
await this.safeUnlink(tempPath);
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException(MESSAGES.UPLOAD.INVALID_VIDEO_FILE);
}
const fileInfo = await stat(tempPath);
return {
tempPath,
outputExtension: '.mp4',
mimeType: 'video/mp4',
fileSize: fileInfo.size,
};
}
return {
tempPath: file.path,
outputExtension: extname(file.originalname),
mimeType: file.mimetype,
fileSize: file.size,
};
}
private async buildFinalFileName(
absoluteDir: string,
originalName: string,
outputExtension: string,
date: Date,
) {
const timestamp = this.formatDateToSecond(date);
const originalBaseName =
this.normalizeOriginalBaseName(originalName) || 'upload-file';
const extension = outputExtension || extname(originalName) || '';
const baseFileName = `${timestamp}-${originalBaseName}`;
let candidate = `${baseFileName}${extension}`;
let duplicateIndex = 1;
while (await this.fileExists(join(absoluteDir, candidate))) {
candidate = `${baseFileName}-${duplicateIndex}${extension}`;
duplicateIndex += 1;
}
return candidate;
}
private normalizeOriginalBaseName(originalName: string) {
const rawBaseName = originalName.replace(/\.[^.]+$/, '');
const sanitized = rawBaseName
.normalize('NFKC')
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
.replace(/\s+/g, ' ')
.trim()
.replace(/-+/g, '-');
return sanitized || 'upload-file';
}
private formatDateToSecond(date: Date) {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}${month}${day}${hours}${minutes}${seconds}`;
}
private async fileExists(path: string) {
try {
await access(path);
return true;
} catch {
return false;
}
}
private async compressVideo(inputPath: string, outputPath: string) {
const command = ffmpegPath as unknown as string | null;
if (!command) {
throw new InternalServerErrorException(MESSAGES.UPLOAD.FFMPEG_NOT_AVAILABLE);
}
const args = [
'-y',
'-i',
inputPath,
'-map',
'0:v:0',
'-map',
'0:a?',
'-vf',
"scale='if(gt(iw,ih),min(1280,iw),-2)':'if(gt(iw,ih),-2,min(1280,ih))'",
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
'29',
'-pix_fmt',
'yuv420p',
'-movflags',
'+faststart',
'-c:a',
'aac',
'-b:a',
'128k',
outputPath,
];
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args);
let stderr = '';
child.stderr?.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(
new BadRequestException(
stderr.trim() || MESSAGES.UPLOAD.INVALID_VIDEO_FILE,
),
);
});
});
}
private resolveReadableHospitalScope(
actor: ActorContext,
requestedHospitalId?: number,
) {
if (actor.role === Role.SYSTEM_ADMIN) {
return requestedHospitalId && requestedHospitalId > 0
? requestedHospitalId
: null;
}
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.UPLOAD.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
private async safeUnlink(path: string | undefined) {
if (!path) {
return;
}
try {
await unlink(path);
} catch {
// 临时文件清理失败不影响主流程,避免吞掉原始错误。
}
}
}

View File

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

View File

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

View File

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

View File

@ -38,7 +38,7 @@ export class UsersController {
* *
*/ */
@Post() @Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建用户' }) @ApiOperation({ summary: '创建用户' })
create( create(
@CurrentActor() actor: ActorContext, @CurrentActor() actor: ActorContext,
@ -61,7 +61,7 @@ export class UsersController {
* *
*/ */
@Get(':id') @Get(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
@ApiOperation({ summary: '查询用户详情' }) @ApiOperation({ summary: '查询用户详情' })
@ApiParam({ name: 'id', description: '用户 ID' }) @ApiParam({ name: 'id', description: '用户 ID' })
findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) { findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
@ -72,7 +72,7 @@ export class UsersController {
* *
*/ */
@Patch(':id') @Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新用户' }) @ApiOperation({ summary: '更新用户' })
@ApiParam({ name: 'id', description: '用户 ID' }) @ApiParam({ name: 'id', description: '用户 ID' })
update( update(
@ -87,7 +87,7 @@ export class UsersController {
* *
*/ */
@Delete(':id') @Delete(':id')
@Roles(Role.SYSTEM_ADMIN, Role.DIRECTOR) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '删除用户' }) @ApiOperation({ summary: '删除用户' })
@ApiParam({ name: 'id', description: '用户 ID' }) @ApiParam({ name: 'id', description: '用户 ID' })
remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) { remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) {

View File

@ -30,6 +30,9 @@ const SAFE_USER_SELECT = {
groupId: true, groupId: true,
} as const; } as const;
const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const;
const LEADER_VISIBLE_ROLES = [Role.DOCTOR] as const;
@Injectable() @Injectable()
export class UsersService { export class UsersService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@ -202,15 +205,18 @@ export class UsersService {
actor.hospitalId, actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED, MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
); );
} else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) { } else if (actor.role === Role.DIRECTOR) {
where.hospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
where.departmentId = this.requireActorScopeInt( where.departmentId = this.requireActorScopeInt(
actor.departmentId, actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED, MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
); );
where.role = { in: [...DIRECTOR_VISIBLE_ROLES] };
} else if (actor.role === Role.LEADER) {
where.groupId = this.requireActorScopeInt(
actor.groupId,
MESSAGES.ORG.ACTOR_GROUP_REQUIRED,
);
where.role = { in: [...LEADER_VISIBLE_ROLES] };
} else if (actor.role !== Role.SYSTEM_ADMIN) { } else if (actor.role !== Role.SYSTEM_ADMIN) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
} }
@ -682,38 +688,9 @@ export class UsersService {
} }
if (actor.role !== Role.HOSPITAL_ADMIN) { if (actor.role !== Role.HOSPITAL_ADMIN) {
if (actor.role !== Role.DIRECTOR) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN); throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
} }
// 科室主任仅允许创建本科室医生。
if (targetRole !== Role.DOCTOR) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
if (hospitalId != null && hospitalId !== actorHospitalId) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
if (departmentId != null && departmentId !== actorDepartmentId) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
return {
hospitalId: actorHospitalId,
departmentId: actorDepartmentId,
groupId,
};
}
if ( if (
targetRole === Role.SYSTEM_ADMIN || targetRole === Role.SYSTEM_ADMIN ||
targetRole === Role.HOSPITAL_ADMIN targetRole === Role.HOSPITAL_ADMIN
@ -740,7 +717,7 @@ export class UsersService {
} }
/** /**
* *
*/ */
private assertUserReadable( private assertUserReadable(
actor: ActorContext, actor: ActorContext,
@ -749,6 +726,7 @@ export class UsersService {
role: Role; role: Role;
hospitalId: number | null; hospitalId: number | null;
departmentId: number | null; departmentId: number | null;
groupId: number | null;
}, },
) { ) {
if (actor.role === Role.SYSTEM_ADMIN) { if (actor.role === Role.SYSTEM_ADMIN) {
@ -769,30 +747,36 @@ export class UsersService {
} }
if (actor.role === Role.DIRECTOR) { if (actor.role === Role.DIRECTOR) {
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
const actorDepartmentId = this.requireActorScopeInt( const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId, actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED, MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
); );
if ( if (
target.role === Role.DOCTOR && target.departmentId === actorDepartmentId &&
target.hospitalId === actorHospitalId && (DIRECTOR_VISIBLE_ROLES as readonly Role[]).includes(target.role)
target.departmentId === actorDepartmentId
) { ) {
return; return;
} }
}
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN); if (actor.role === Role.LEADER) {
const actorGroupId = this.requireActorScopeInt(
actor.groupId,
MESSAGES.ORG.ACTOR_GROUP_REQUIRED,
);
if (
target.groupId === actorGroupId &&
(LEADER_VISIBLE_ROLES as readonly Role[]).includes(target.role)
) {
return;
}
} }
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
} }
/** /**
* *
*/ */
private assertUserWritable( private assertUserWritable(
actor: ActorContext, actor: ActorContext,
@ -807,25 +791,6 @@ export class UsersService {
return; return;
} }
if (actor.role === Role.DIRECTOR) {
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
if (
target.role !== Role.DOCTOR ||
target.hospitalId !== actorHospitalId ||
target.departmentId !== actorDepartmentId
) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
return;
}
if (actor.role !== Role.HOSPITAL_ADMIN) { if (actor.role !== Role.HOSPITAL_ADMIN) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
} }
@ -857,10 +822,6 @@ export class UsersService {
return; return;
} }
if (actor.role === Role.DIRECTOR && nextRole !== Role.DOCTOR) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
if ( if (
actor.role === Role.HOSPITAL_ADMIN && actor.role === Role.HOSPITAL_ADMIN &&
(nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN) (nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN)
@ -878,17 +839,6 @@ export class UsersService {
actor: ActorContext, actor: ActorContext,
hospitalId: number | null, hospitalId: number | null,
) { ) {
if (actor.role === Role.DIRECTOR) {
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
if (hospitalId !== actorHospitalId) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
return;
}
if (actor.role !== Role.HOSPITAL_ADMIN) { if (actor.role !== Role.HOSPITAL_ADMIN) {
return; return;
} }
@ -911,17 +861,9 @@ export class UsersService {
actor: ActorContext, actor: ActorContext,
departmentId: number | null, departmentId: number | null,
) { ) {
if (actor.role !== Role.DIRECTOR) { if (actor.role !== Role.HOSPITAL_ADMIN) {
return; return;
} }
const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
if (departmentId !== actorDepartmentId) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
} }
/** /**

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B

View File

@ -0,0 +1 @@
upload-SYSTEM_ADMIN

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B

View File

@ -0,0 +1 @@
upload-SYSTEM_ADMIN

View File

@ -0,0 +1 @@
fake-image-content

View File

@ -0,0 +1 @@
fake-image-content

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

View File

@ -0,0 +1 @@
upload-HOSPITAL_ADMIN

View File

@ -0,0 +1 @@
upload-DIRECTOR

Some files were not shown because too many files have changed in this diff Show More