From 0b5640a97722aa013463206ec4f9c0074040cbd1 Mon Sep 17 00:00:00 2001 From: EL <1175065040@qq.com> Date: Fri, 20 Mar 2026 06:03:09 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E5=8E=8B=E4=BB=BB=E5=8A=A1=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E4=BB=8E=E2=80=9C=E5=8F=91=E5=B8=83=E5=8D=B3=E6=8C=87?= =?UTF-8?q?=E6=B4=BE=E2=80=9D=E6=94=B9=E4=B8=BA=E2=80=9C=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=BE=85=E6=8E=A5=E6=94=B6(PENDING)=20->=20=E5=B7=A5=E7=A8=8B?= =?UTF-8?q?=E5=B8=88=E6=8E=A5=E6=94=B6(ACCEPTED)=20->=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?(COMPLETED)=E2=80=9D=E3=80=82=20=E6=96=B0=E5=A2=9E=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E5=B8=88=E2=80=9C=E5=8F=96=E6=B6=88=E6=8E=A5=E6=94=B6?= =?UTF-8?q?=E2=80=9D=E8=83=BD=E5=8A=9B=EF=BC=8C=E4=BB=BB=E5=8A=A1=E5=8F=AF?= =?UTF-8?q?=E4=BB=8E=20ACCEPTED=20=E5=9B=9E=E9=80=80=E5=88=B0=20PENDING?= =?UTF-8?q?=E3=80=82=20=E5=8F=91=E5=B8=83=E4=BB=BB=E5=8A=A1=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A6=81=E6=B1=82=20engineerId=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=90=8C=E8=AE=BE=E5=A4=87=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E6=9C=AA=E7=BB=93=E6=9D=9F=E4=BB=BB=E5=8A=A1=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=8F=91=E5=B8=83=E6=8B=A6=E6=88=AA=E3=80=82?= =?UTF-8?q?=20=E5=AE=8C=E6=88=90=E4=BB=BB=E5=8A=A1=E6=96=B0=E5=A2=9E=20com?= =?UTF-8?q?pletionMaterials=20=E5=BF=85=E5=A1=AB=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=8C=E4=BB=85=E5=85=81=E8=AE=B8=E5=9B=BE=E7=89=87/?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=87=AD=E8=AF=81=EF=BC=8C=E5=B9=B6=E5=9C=A8?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=97=B6=E8=90=BD=E5=BA=93=E3=80=82=20?= =?UTF-8?q?=E6=A4=8D=E5=85=A5=E7=89=A9=E7=9B=AE=E5=BD=95=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20isValve=EF=BC=8C=E5=8C=BA=E5=88=86=E9=98=80=E9=97=A8?= =?UTF-8?q?=E4=B8=8E=E7=AE=A1=E5=AD=90=EF=BC=9B=E9=9D=9E=E9=98=80=E9=97=A8?= =?UTF-8?q?=E4=B8=8D=E7=BB=B4=E6=8A=A4=E5=8E=8B=E5=8A=9B=E6=8C=A1=E4=BD=8D?= =?UTF-8?q?=EF=BC=8C=E9=98=80=E9=97=A8=E8=87=B3=E5=B0=91=201=20=E4=B8=AA?= =?UTF-8?q?=E6=8C=A1=E4=BD=8D=E3=80=82=20=E6=82=A3=E8=80=85=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E4=B8=8E=E4=BB=BB=E5=8A=A1=E6=9F=A5=E8=AF=A2=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E6=96=B0=E5=A2=9E=E5=AD=97=E6=AE=B5=EF=BC=8C=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=BB=BB=E5=8A=A1=E9=A1=B5=E6=94=AF=E6=8C=81=E6=8E=A5?= =?UTF-8?q?=E6=94=B6/=E5=8F=96=E6=B6=88=E6=8E=A5=E6=94=B6/=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=87=AD=E8=AF=81=E5=90=8E=E5=AE=8C=E6=88=90=E3=80=82?= =?UTF-8?q?=20=E5=A2=9E=E8=A1=A5=20Prisma=20=E8=BF=81=E7=A7=BB=E3=80=81?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=96=87=E6=A1=A3=E3=80=81E2E=20=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E4=B8=8E=E5=A4=B9=E5=85=B7=E4=BF=AE=E5=A4=8D=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 +- docs/devices.md | 8 +- docs/frontend-api-integration.md | 10 +- docs/patients.md | 2 + docs/tasks.md | 22 +- docs/uploads.md | 3 +- .../migration.sql | 25 + .../migration.sql | 2 + prisma/schema.prisma | 25 +- src/common/messages.ts | 14 +- src/devices/devices.service.ts | 37 +- src/devices/dto/create-implant-catalog.dto.ts | 18 +- .../task-events.listener.ts | 10 +- src/patients/b-patients/b-patients.service.ts | 39 +- src/patients/c-patients/c-patients.service.ts | 4 + src/patients/dto/create-surgery-device.dto.ts | 8 +- src/tasks/b-tasks/b-tasks.controller.ts | 14 +- src/tasks/dto/complete-task.dto.ts | 13 +- src/tasks/dto/publish-task.dto.ts | 6 - src/tasks/dto/task-completion-material.dto.ts | 42 ++ src/tasks/task.service.ts | 242 +++++++- src/uploads/b-uploads/b-uploads.controller.ts | 6 +- test/e2e/helpers/e2e-fixtures.helper.ts | 542 +++++++++++++++--- test/e2e/specs/devices.e2e-spec.ts | 30 +- test/e2e/specs/patients.e2e-spec.ts | 51 +- test/e2e/specs/tasks.e2e-spec.ts | 422 +++++++++++--- test/e2e/specs/uploads.e2e-spec.ts | 13 +- tyt-admin/src/api/tasks.js | 4 + tyt-admin/src/views/devices/Devices.vue | 64 ++- tyt-admin/src/views/patients/Patients.vue | 377 +++++++----- .../components/SurgeryFormSection.vue | 44 +- tyt-admin/src/views/tasks/Tasks.vue | 449 ++++++++++++++- 32 files changed, 2124 insertions(+), 428 deletions(-) create mode 100644 prisma/migrations/20260320113000_task_claim_and_implant_valve/migration.sql create mode 100644 prisma/migrations/20260320142000_task_completion_materials/migration.sql create mode 100644 src/tasks/dto/task-completion-material.dto.ts diff --git a/.gitignore b/.gitignore index ffc55f9..cc87309 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,8 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json /src/generated/prisma /tyt-admin/dist -/tyt-admin/node_modules \ No newline at end of file +/tyt-admin/node_modules + +# Runtime upload assets +/storage/uploads +/storage/tmp-uploads diff --git a/docs/devices.md b/docs/devices.md index d96385c..d45f182 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -4,7 +4,7 @@ - 提供“全局植入物目录”管理,供患者手术表单选择。 - 维护患者手术下的植入实例记录。 -- 支持为可调压器械配置挡位列表。 +- 支持区分“阀门 / 管子”,并仅为阀门配置挡位列表。 - 支持管理员按医院、患者、状态和关键词分页查询患者植入实例。 ## 2. 设备实例 @@ -17,6 +17,7 @@ - `surgeryId`:归属手术,可为空 - `implantCatalogId`:型号字典 ID,可为空 - `implantModel` / `implantManufacturer` / `implantName`:历史快照 +- `isValve`:是否为阀门 - `isPressureAdjustable`:是否可调压 - `isAbandoned`:是否弃用 - `currentPressure`:当前压力挡位标签 @@ -35,8 +36,9 @@ - `modelCode`:型号编码,唯一 - `manufacturer`:厂商 - `name`:名称 +- `isValve`:是否为阀门;关闭时表示管子或附件 - `pressureLevels`:可调压器械的挡位字符串标签列表 -- `isPressureAdjustable`:是否可调压 +- `isPressureAdjustable`:后端按 `isValve` 自动派生 - `notes`:目录备注 可见性: @@ -47,6 +49,8 @@ 说明: +- 非阀门目录项不会保存压力挡位,前端也不会显示压力录入区域。 +- 阀门目录项至少需要配置一个挡位。 - 挡位列表按字符串标签保存,例如 `["0.5", "1", "1.5"]` 或 `["10", "20", "30"]`。 - 保存前会自动标准化并去重排序,例如 `["01.0", "1.50", "1"]` 最终会整理为 `["1", "1.5"]`。 diff --git a/docs/frontend-api-integration.md b/docs/frontend-api-integration.md index 445111b..1db710c 100644 --- a/docs/frontend-api-integration.md +++ b/docs/frontend-api-integration.md @@ -5,7 +5,7 @@ - 登录页:`/auth/login`,支持可选 `hospitalId`。 - 首页看板:按角色拉取组织与患者统计。 - 设备页:新增管理员专用设备 CRUD,复用真实设备接口。 -- 任务页:改为只读调压记录页,接入真实任务列表接口。 +- 任务页:接入真实任务列表、工程师接收与完成接口。 - 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。 - 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`), 后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。 @@ -20,7 +20,7 @@ - `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 /b/tasks/engineers` 返回当前角色可见的医院工程师列表。 - `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCard`。 - 患者表单中的 `idCard` 字段直接传身份证号; 服务端只会做去空格与 `x/X` 标准化,不会转哈希。 @@ -29,8 +29,8 @@ ## 3. 角色权限提示 - 任务接口权限: - - `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时必须指定接收工程师;可取消自己创建的任务 - - `ENGINEER`:仅可完成分配给自己的任务 + - `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时不再指定接收工程师;可取消自己创建的任务 + - `ENGINEER`:可接收本院待接收任务;仅可完成自己已接收的任务 - 患者列表权限: - `SYSTEM_ADMIN` 查询时必须传 `hospitalId` - 用户管理接口: @@ -61,7 +61,7 @@ - `patients` - `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问 -患者页负责发起调压任务并指定接收人,任务页仅查看调压记录,不再提供发布或接收入口。 +患者页负责发起调压任务,任务页负责查看、接收与完成调压任务。 患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。 diff --git a/docs/patients.md b/docs/patients.md index e6e632f..17e8f38 100644 --- a/docs/patients.md +++ b/docs/patients.md @@ -58,6 +58,7 @@ - `implantCatalogId`:植入物型号字典 ID - `implantModel` / `implantManufacturer` / `implantName`:型号快照 +- `isValve`:是否为阀门 - `isPressureAdjustable`:是否可调压 - `isAbandoned`:是否已弃用 - `shuntMode`:分流方式 @@ -75,6 +76,7 @@ - 旧设备弃用后,`TaskItem` 历史不会删除。 - 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。 - 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。 +- 管子/附件类型不会显示“阀门植入部位”和“初始压力”录入项。 - 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的字符串标签值,例如 `0.5 / 1 / 1.5 / 10 / 20 / 30`。 - 挡位标签保存前会自动标准化,例如 `01.0 -> 1`、`1.50 -> 1.5`。 - 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`;发布调压任务时不会立刻改当前压力,只有工程师完成任务后才会更新。 diff --git a/docs/tasks.md b/docs/tasks.md index 109607e..9eabf13 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -7,24 +7,26 @@ ## 2. 状态机 -- 当前发布流程:`ACCEPTED -> COMPLETED` -- 当前取消流程:`ACCEPTED -> CANCELLED` -- 历史兼容:保留 `PENDING` 状态枚举,便于兼容旧数据与旧任务记录 +- 当前发布流程:`PENDING -> ACCEPTED -> COMPLETED` +- 当前工程师撤回流程:`ACCEPTED -> PENDING` +- 当前取消流程:`PENDING/ACCEPTED -> CANCELLED` +- `PENDING` 表示任务已发布,等待本院工程师接收 非法流转会返回 `409` 冲突错误(中文消息)。 ## 3. 角色权限 -- 系统管理员/医院管理员/医生/主任/组长:发布任务时必须直接指定接收工程师,并且只能取消自己创建的任务 -- 工程师:不能再执行“接收”动作,只能完成指派给自己的任务 +- 系统管理员/医院管理员/医生/主任/组长:发布任务时不再指定工程师,只能取消自己创建的任务 +- 工程师:可接收本院 `PENDING` 任务;接收后只能由接收工程师自己完成,或取消接收并退回 `PENDING` - 其他角色:默认拒绝 补充: -- `GET /b/tasks/engineers`:返回当前角色可选的接收工程师列表,系统管理员可按医院筛选。 +- `GET /b/tasks/engineers`:返回当前角色可见的医院工程师列表,系统管理员可按医院筛选。 - `GET /b/tasks`:返回当前角色可见的调压记录列表,系统管理员可按医院筛选。 - `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。 - 当前取消原因仅透传到事件层,数据库暂未持久化该字段。 +- 如果当前设备已经存在 `PENDING / ACCEPTED` 调压任务,则禁止再次发布;同一患者的其他设备不受影响。 ## 4. 记录列表 @@ -35,6 +37,7 @@ - 手术名称 - 设备信息 - 旧压力 / 目标压力 / 当前压力(均为字符串挡位标签) + - 完成凭证(图片/视频) - 创建人 / 接收人 / 发布时间 ## 5. 事件触发 @@ -42,6 +45,7 @@ 状态变化后会发出事件: - `task.published` +- `task.accepted` - `task.completed` - `task.cancelled` @@ -52,8 +56,9 @@ `completeTask` 在单事务中执行: 1. 更新任务状态为 `COMPLETED` -2. 读取 `TaskItem.targetPressure` -3. 批量更新关联 `Device.currentPressure` +2. 校验至少上传 1 条图片或视频凭证 +3. 读取 `TaskItem.targetPressure` +4. 批量更新关联 `Device.currentPressure` 确保任务状态与设备压力一致性。 @@ -61,3 +66,4 @@ - `publishTask` 只负责生成任务和目标挡位,不会立刻修改设备当前压力。 - 只有工程师完成任务后,目标挡位才会回写到设备实例。 +- 完成任务时必须上传至少一张图片或一个视频,凭证会保存到 `Task.completionMaterials`。 diff --git a/docs/uploads.md b/docs/uploads.md index ca9d9ff..0460d14 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -33,7 +33,7 @@ ## 3. 接口 - `POST /b/uploads` - - 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN / DIRECTOR / LEADER / DOCTOR` + - 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN / DIRECTOR / LEADER / DOCTOR / ENGINEER` - 表单字段: - `file`:二进制文件 - `hospitalId`:仅 `SYSTEM_ADMIN` 上传时必填 @@ -50,6 +50,7 @@ - 患者手术表单中的“术前 CT 影像/资料”支持直接上传,上传成功后自动回填 `type/name/url`。 - 设备表单中的“植入物标签”支持直接上传图片,上传成功后自动回填 `labelImageUrl`。 +- 工程师完成调压任务时,可直接上传图片或视频作为完成凭证。 - 患者详情页会直接预览术前图片、视频和设备标签。 - 单独新增“影像库”页面,按图片/视频/文件分页查看所有上传资产。 - 页面访问权限仅 `SYSTEM_ADMIN / HOSPITAL_ADMIN` diff --git a/prisma/migrations/20260320113000_task_claim_and_implant_valve/migration.sql b/prisma/migrations/20260320113000_task_claim_and_implant_valve/migration.sql new file mode 100644 index 0000000..2b26c2a --- /dev/null +++ b/prisma/migrations/20260320113000_task_claim_and_implant_valve/migration.sql @@ -0,0 +1,25 @@ +ALTER TABLE "ImplantCatalog" +ADD COLUMN IF NOT EXISTS "isValve" BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE "Device" +ADD COLUMN IF NOT EXISTS "isValve" BOOLEAN NOT NULL DEFAULT true; + +UPDATE "ImplantCatalog" +SET "isPressureAdjustable" = CASE + WHEN "isValve" THEN true + ELSE false +END +WHERE "isPressureAdjustable" IS DISTINCT FROM CASE + WHEN "isValve" THEN true + ELSE false +END; + +UPDATE "Device" +SET "isPressureAdjustable" = CASE + WHEN "isValve" THEN true + ELSE false +END +WHERE "isPressureAdjustable" IS DISTINCT FROM CASE + WHEN "isValve" THEN true + ELSE false +END; diff --git a/prisma/migrations/20260320142000_task_completion_materials/migration.sql b/prisma/migrations/20260320142000_task_completion_materials/migration.sql new file mode 100644 index 0000000..65d192a --- /dev/null +++ b/prisma/migrations/20260320142000_task_completion_materials/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Task" +ADD COLUMN IF NOT EXISTS "completionMaterials" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 38d7018..36d92e8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -180,6 +180,8 @@ model ImplantCatalog { modelCode String @unique manufacturer String name String + // 是否为阀门;关闭时表示管子/附件,不提供压力挡位。 + isValve Boolean @default(true) // 可调压器械的可选挡位,由系统管理员维护。 pressureLevels String[] @default([]) isPressureAdjustable Boolean @default(true) @@ -235,6 +237,7 @@ model Device { implantModel String? implantManufacturer String? implantName String? + isValve Boolean @default(true) isPressureAdjustable Boolean @default(true) // 二次手术后旧设备可标记弃用,但历史调压任务仍需保留。 isAbandoned Boolean @default(false) @@ -258,16 +261,18 @@ model Device { // 主任务表:记录调压任务主单。 model Task { - id Int @id @default(autoincrement()) - status TaskStatus @default(PENDING) - creatorId Int - engineerId Int? - hospitalId Int - createdAt DateTime @default(now()) - creator User @relation("TaskCreator", fields: [creatorId], references: [id]) - engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id]) - hospital Hospital @relation(fields: [hospitalId], references: [id]) - items TaskItem[] + id Int @id @default(autoincrement()) + status TaskStatus @default(PENDING) + creatorId Int + engineerId Int? + hospitalId Int + createdAt DateTime @default(now()) + // 工程师完成任务时上传的图片/视频凭证。 + completionMaterials Json? + creator User @relation("TaskCreator", fields: [creatorId], references: [id]) + engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id]) + hospital Hospital @relation(fields: [hospitalId], references: [id]) + items TaskItem[] @@index([hospitalId, status, createdAt]) } diff --git a/src/common/messages.ts b/src/common/messages.ts index 3facf45..6670ed9 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -65,15 +65,18 @@ export const MESSAGES = { ITEMS_REQUIRED: '任务明细 items 不能为空', DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在', DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院', + DUPLICATE_DEVICE_OPEN_TASK: '该设备已有待处理调压任务,请勿重复发布', ENGINEER_REQUIRED: '接收工程师必选', ENGINEER_INVALID: '工程师必须为当前医院有效工程师', TASK_NOT_FOUND: '任务不存在或不属于当前医院', - ACCEPT_DISABLED: '当前流程不支持工程师接收,请由创建人直接指定接收工程师', - ACCEPT_ONLY_PENDING: '仅待指派任务可执行接收', - COMPLETE_ONLY_ACCEPTED: '仅已指派任务可执行完成', - CANCEL_ONLY_PENDING_ACCEPTED: '仅待指派/已指派任务可取消', - ENGINEER_ALREADY_ASSIGNED: '任务已指派给其他工程师', + ACCEPT_ONLY_PENDING: '仅待接收任务可执行接收', + COMPLETE_ONLY_ACCEPTED: '仅已接收任务可执行完成', + COMPLETE_MATERIALS_REQUIRED: '完成任务至少上传一张图片或一个视频', + COMPLETE_MATERIAL_TYPE_INVALID: '完成任务仅支持图片或视频凭证', + CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消', + ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收', ENGINEER_ONLY_ASSIGNEE: '仅任务接收人可完成任务', + CANCEL_ONLY_ASSIGNEE: '仅任务接收人可取消接收', CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务', ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作', ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息', @@ -113,6 +116,7 @@ export const MESSAGES = { CATALOG_MODEL_DUPLICATE: '植入物型号编码已存在', CATALOG_SCOPE_FORBIDDEN: '当前角色无权限维护该植入物型号', CATALOG_DELETE_CONFLICT: '植入物型号已被患者手术引用,无法删除', + VALVE_PRESSURE_REQUIRED: '阀门类型至少需要配置一个压力挡位', PRESSURE_LEVEL_INVALID: '压力值不在该植入物配置的挡位范围内', DEVICE_NOT_ADJUSTABLE: '仅可调压设备允许创建调压任务', }, diff --git a/src/devices/devices.service.ts b/src/devices/devices.service.ts index f7eec38..581215b 100644 --- a/src/devices/devices.service.ts +++ b/src/devices/devices.service.ts @@ -25,6 +25,7 @@ const CATALOG_SELECT = { modelCode: true, manufacturer: true, name: true, + isValve: true, pressureLevels: true, isPressureAdjustable: true, notes: true, @@ -214,7 +215,8 @@ export class DevicesService { */ async createCatalog(actor: ActorContext, dto: CreateImplantCatalogDto) { this.assertSystemAdmin(actor); - const isPressureAdjustable = dto.isPressureAdjustable ?? true; + const isValve = dto.isValve ?? true; + const isPressureAdjustable = isValve; try { return await this.prisma.implantCatalog.create({ @@ -225,9 +227,10 @@ export class DevicesService { 'manufacturer', ), name: this.normalizeRequiredString(dto.name, 'name'), + isValve, pressureLevels: this.normalizePressureLevels( dto.pressureLevels, - isPressureAdjustable, + isValve, ), isPressureAdjustable, notes: @@ -258,8 +261,8 @@ export class DevicesService { ) { this.assertSystemAdmin(actor); const current = await this.findWritableCatalog(id); - const nextIsPressureAdjustable = - dto.isPressureAdjustable ?? current.isPressureAdjustable; + const nextIsValve = dto.isValve ?? current.isValve; + const nextIsPressureAdjustable = nextIsValve; const data: Prisma.ImplantCatalogUpdateInput = {}; if (dto.modelCode !== undefined) { @@ -274,16 +277,14 @@ export class DevicesService { if (dto.name !== undefined) { data.name = this.normalizeRequiredString(dto.name, 'name'); } - if (dto.isPressureAdjustable !== undefined) { - data.isPressureAdjustable = dto.isPressureAdjustable; + if (dto.isValve !== undefined) { + data.isValve = dto.isValve; + data.isPressureAdjustable = nextIsPressureAdjustable; } - if ( - dto.pressureLevels !== undefined || - dto.isPressureAdjustable !== undefined - ) { + if (dto.pressureLevels !== undefined || dto.isValve !== undefined) { data.pressureLevels = this.normalizePressureLevels( dto.pressureLevels ?? current.pressureLevels, - nextIsPressureAdjustable, + nextIsValve, ); } if (dto.notes !== undefined) { @@ -606,13 +607,21 @@ export class DevicesService { */ private normalizePressureLevels( pressureLevels: unknown[] | undefined, - isPressureAdjustable: boolean, + isValve: boolean, ) { - if (!isPressureAdjustable) { + if (!isValve) { return []; } - return normalizePressureLabelList(pressureLevels, 'pressureLevels'); + const normalized = normalizePressureLabelList( + pressureLevels, + 'pressureLevels', + ); + if (normalized.length === 0) { + throw new BadRequestException(MESSAGES.DEVICE.VALVE_PRESSURE_REQUIRED); + } + + return normalized; } /** diff --git a/src/devices/dto/create-implant-catalog.dto.ts b/src/devices/dto/create-implant-catalog.dto.ts index 14b99f9..96dbcd5 100644 --- a/src/devices/dto/create-implant-catalog.dto.ts +++ b/src/devices/dto/create-implant-catalog.dto.ts @@ -34,6 +34,15 @@ export class CreateImplantCatalogDto { @IsString({ message: 'name 必须是字符串' }) name!: string; + @ApiPropertyOptional({ + description: '是否为阀门,关闭时表示管子或附件', + example: true, + }) + @IsOptional() + @ToBoolean() + @IsBoolean({ message: 'isValve 必须是布尔值' }) + isValve?: boolean; + @ApiPropertyOptional({ description: '可调压器械的挡位列表,按字符串挡位标签录入', type: [String], @@ -45,15 +54,6 @@ export class CreateImplantCatalogDto { @IsString({ each: true, message: 'pressureLevels 必须为字符串数组' }) pressureLevels?: string[]; - @ApiPropertyOptional({ - description: '是否支持调压,默认 true', - example: true, - }) - @IsOptional() - @ToBoolean() - @IsBoolean({ message: 'isPressureAdjustable 必须是布尔值' }) - isPressureAdjustable?: boolean; - @ApiPropertyOptional({ description: '植入物备注', example: '适用于儿童脑积水病例', diff --git a/src/notifications/task-events.listener/task-events.listener.ts b/src/notifications/task-events.listener/task-events.listener.ts index 0934fb9..b1d365c 100644 --- a/src/notifications/task-events.listener/task-events.listener.ts +++ b/src/notifications/task-events.listener/task-events.listener.ts @@ -23,7 +23,7 @@ export class TaskEventsListener { ) {} /** - * 任务发布事件:通知创建医生与指定工程师(如有)。 + * 任务发布事件:通知创建人和已绑定 openId 的接收工程师(如有)。 */ @OnEvent('task.published', { async: true }) async onTaskPublished(payload: TaskEventPayload) { @@ -54,6 +54,14 @@ export class TaskEventsListener { await this.dispatchTaskEvent('task.cancelled', payload); } + /** + * 工程师取消接收事件。 + */ + @OnEvent('task.released', { async: true }) + async onTaskReleased(payload: TaskEventPayload) { + await this.dispatchTaskEvent('task.released', payload); + } + /** * 统一处理任务事件并派发通知目标。 */ diff --git a/src/patients/b-patients/b-patients.service.ts b/src/patients/b-patients/b-patients.service.ts index d86a025..10f7ad4 100644 --- a/src/patients/b-patients/b-patients.service.ts +++ b/src/patients/b-patients/b-patients.service.ts @@ -25,6 +25,7 @@ const IMPLANT_CATALOG_SELECT = { modelCode: true, manufacturer: true, name: true, + isValve: true, pressureLevels: true, isPressureAdjustable: true, notes: true, @@ -43,6 +44,7 @@ const PATIENT_LIST_INCLUDE = { implantModel: true, implantManufacturer: true, implantName: true, + isValve: true, isPressureAdjustable: true, }, orderBy: { id: 'desc' }, @@ -627,7 +629,7 @@ export class BPatientsService { } const initialPressure = - device.initialPressure == null + !catalog.isValve || device.initialPressure == null ? null : this.assertPressureLevelAllowed( catalog, @@ -637,10 +639,10 @@ export class BPatientsService { ), ); const fallbackPressureLevel = - catalog.isPressureAdjustable && catalog.pressureLevels.length > 0 + catalog.isValve && catalog.pressureLevels.length > 0 ? catalog.pressureLevels[0] : '0'; - const currentPressure = catalog.isPressureAdjustable + const currentPressure = catalog.isValve ? this.assertPressureLevelAllowed( catalog, initialPressure ?? fallbackPressureLevel, @@ -655,6 +657,7 @@ export class BPatientsService { implantModel: catalog.modelCode, implantManufacturer: catalog.manufacturer, implantName: catalog.name, + isValve: catalog.isValve, isPressureAdjustable: catalog.isPressureAdjustable, isAbandoned: false, shuntMode: this.normalizeRequiredString(device.shuntMode, 'shuntMode'), @@ -662,10 +665,15 @@ export class BPatientsService { device.proximalPunctureAreas, 'proximalPunctureAreas', ), - valvePlacementSites: this.normalizeStringArray( - device.valvePlacementSites, - 'valvePlacementSites', - ), + valvePlacementSites: catalog.isValve + ? this.normalizeStringArray( + device.valvePlacementSites, + 'valvePlacementSites', + ) + : this.normalizeOptionalStringArray( + device.valvePlacementSites, + 'valvePlacementSites', + ), distalShuntDirection: this.normalizeRequiredString( device.distalShuntDirection, 'distalShuntDirection', @@ -780,12 +788,14 @@ export class BPatientsService { */ private assertPressureLevelAllowed( catalog: { + isValve: boolean; isPressureAdjustable: boolean; pressureLevels: string[]; }, pressure: string, ) { if ( + catalog.isValve && catalog.isPressureAdjustable && Array.isArray(catalog.pressureLevels) && catalog.pressureLevels.length > 0 && @@ -930,6 +940,21 @@ export class BPatientsService { ); } + private normalizeOptionalStringArray(value: unknown, fieldName: string) { + if (value == null) { + return []; + } + if (!Array.isArray(value) || value.length === 0) { + return []; + } + + return Array.from( + new Set( + value.map((item) => this.normalizeRequiredString(item, fieldName)), + ), + ); + } + private normalizePreOpMaterials( materials: CreatePatientSurgeryDto['preOpMaterials'], ): Prisma.InputJsonArray { diff --git a/src/patients/c-patients/c-patients.service.ts b/src/patients/c-patients/c-patients.service.ts index 970f90a..3c3b2d3 100644 --- a/src/patients/c-patients/c-patients.service.ts +++ b/src/patients/c-patients/c-patients.service.ts @@ -42,6 +42,7 @@ export class CPatientsService { modelCode: true, manufacturer: true, name: true, + isValve: true, pressureLevels: true, isPressureAdjustable: true, notes: true, @@ -67,6 +68,7 @@ export class CPatientsService { modelCode: true, manufacturer: true, name: true, + isValve: true, pressureLevels: true, isPressureAdjustable: true, notes: true, @@ -117,6 +119,7 @@ export class CPatientsService { implantModel: device.implantModel, implantManufacturer: device.implantManufacturer, implantName: device.implantName, + isValve: device.isValve, isPressureAdjustable: device.isPressureAdjustable, shuntMode: device.shuntMode, distalShuntDirection: device.distalShuntDirection, @@ -154,6 +157,7 @@ export class CPatientsService { implantModel: device.implantModel, implantManufacturer: device.implantManufacturer, implantName: device.implantName, + isValve: device.isValve, isPressureAdjustable: device.isPressureAdjustable, }, surgery: device.surgery diff --git a/src/patients/dto/create-surgery-device.dto.ts b/src/patients/dto/create-surgery-device.dto.ts index d1f3343..f7a47ca 100644 --- a/src/patients/dto/create-surgery-device.dto.ts +++ b/src/patients/dto/create-surgery-device.dto.ts @@ -41,16 +41,16 @@ export class CreateSurgeryDeviceDto { @IsString({ each: true, message: 'proximalPunctureAreas 必须为字符串数组' }) proximalPunctureAreas!: string[]; - @ApiProperty({ - description: '阀门植入部位,最多 2 个', + @ApiPropertyOptional({ + description: '阀门植入部位,阀门型植入物最多 2 个', type: [String], example: ['耳后', '胸前'], }) + @IsOptional() @IsArray({ message: 'valvePlacementSites 必须是数组' }) - @ArrayMinSize(1, { message: 'valvePlacementSites 至少选择 1 项' }) @ArrayMaxSize(2, { message: 'valvePlacementSites 最多选择 2 项' }) @IsString({ each: true, message: 'valvePlacementSites 必须为字符串数组' }) - valvePlacementSites!: string[]; + valvePlacementSites?: string[]; @ApiProperty({ description: '远端分流方向', diff --git a/src/tasks/b-tasks/b-tasks.controller.ts b/src/tasks/b-tasks/b-tasks.controller.ts index 6ed7ae5..c4bc48a 100644 --- a/src/tasks/b-tasks/b-tasks.controller.ts +++ b/src/tasks/b-tasks/b-tasks.controller.ts @@ -30,7 +30,7 @@ export class BTasksController { constructor(private readonly taskService: TaskService) {} /** - * 查询当前角色可指定的接收工程师列表。 + * 查询当前角色可见的医院工程师列表。 */ @Get('engineers') @Roles( @@ -40,7 +40,7 @@ export class BTasksController { Role.DIRECTOR, Role.LEADER, ) - @ApiOperation({ summary: '查询可选接收工程师列表' }) + @ApiOperation({ summary: '查询医院工程师列表' }) @ApiQuery({ name: 'hospitalId', required: false, @@ -97,11 +97,11 @@ export class BTasksController { } /** - * 工程师接收调压任务(当前流程已停用)。 + * 工程师接收调压任务。 */ @Post('accept') @Roles(Role.ENGINEER) - @ApiOperation({ summary: '接收任务(已停用)' }) + @ApiOperation({ summary: '接收任务(ENGINEER)' }) accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) { return this.taskService.acceptTask(actor, dto); } @@ -117,7 +117,7 @@ export class BTasksController { } /** - * 系统管理员/医院管理员/医生/主任/组长取消调压任务(仅任务创建者)。 + * 系统管理员/医院管理员/医生/主任/组长可取消自己创建的任务;工程师可取消自己已接收的任务。 */ @Post('cancel') @Roles( @@ -126,9 +126,11 @@ export class BTasksController { Role.DOCTOR, Role.DIRECTOR, Role.LEADER, + Role.ENGINEER, ) @ApiOperation({ - summary: '取消任务(SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER)', + summary: + '取消任务(SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER/ENGINEER)', }) cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) { return this.taskService.cancelTask(actor, dto); diff --git a/src/tasks/dto/complete-task.dto.ts b/src/tasks/dto/complete-task.dto.ts index 305e192..7d47621 100644 --- a/src/tasks/dto/complete-task.dto.ts +++ b/src/tasks/dto/complete-task.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, Min } from 'class-validator'; +import { ArrayMinSize, IsArray, IsInt, Min, ValidateNested } from 'class-validator'; +import { TaskCompletionMaterialDto } from './task-completion-material.dto.js'; /** * 完成任务 DTO。 @@ -11,4 +12,14 @@ export class CompleteTaskDto { @IsInt({ message: 'taskId 必须是整数' }) @Min(1, { message: 'taskId 必须大于 0' }) taskId!: number; + + @ApiProperty({ + type: [TaskCompletionMaterialDto], + description: '完成任务时上传的图片/视频凭证', + }) + @IsArray({ message: 'completionMaterials 必须是数组' }) + @ArrayMinSize(1, { message: 'completionMaterials 至少上传 1 项' }) + @ValidateNested({ each: true }) + @Type(() => TaskCompletionMaterialDto) + completionMaterials!: TaskCompletionMaterialDto[]; } diff --git a/src/tasks/dto/publish-task.dto.ts b/src/tasks/dto/publish-task.dto.ts index 4e3c81d..2ca0c5d 100644 --- a/src/tasks/dto/publish-task.dto.ts +++ b/src/tasks/dto/publish-task.dto.ts @@ -28,12 +28,6 @@ export class PublishTaskItemDto { * 发布任务 DTO。 */ export class PublishTaskDto { - @ApiProperty({ description: '接收工程师 ID', example: 2 }) - @Type(() => Number) - @IsInt({ message: 'engineerId 必须是整数' }) - @Min(1, { message: 'engineerId 必须大于 0' }) - engineerId!: number; - @ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' }) @IsArray({ message: 'items 必须是数组' }) @ArrayMinSize(1, { message: 'items 至少包含一条明细' }) diff --git a/src/tasks/dto/task-completion-material.dto.ts b/src/tasks/dto/task-completion-material.dto.ts new file mode 100644 index 0000000..d9dae92 --- /dev/null +++ b/src/tasks/dto/task-completion-material.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator'; + +/** + * 任务完成凭证 DTO:仅允许图片或视频。 + */ +export class TaskCompletionMaterialDto { + @ApiProperty({ + description: '上传资产 ID', + example: 1, + }) + @Type(() => Number) + @IsInt({ message: 'assetId 必须是整数' }) + @Min(1, { message: 'assetId 必须大于 0' }) + assetId!: number; + + @ApiProperty({ + description: '资料类型,仅支持图片或视频', + enum: ['IMAGE', 'VIDEO'], + example: 'IMAGE', + }) + @IsIn(['IMAGE', 'VIDEO'], { + message: 'type 必须是 IMAGE 或 VIDEO', + }) + type!: 'IMAGE' | 'VIDEO'; + + @ApiProperty({ + description: '资料访问地址', + example: '/uploads/2026/03/20/20260320123000-proof.webp', + }) + @IsString({ message: 'url 必须是字符串' }) + url!: string; + + @ApiPropertyOptional({ + description: '资料名称', + example: '调压完成照片', + }) + @IsOptional() + @IsString({ message: 'name 必须是字符串' }) + name?: string; +} diff --git a/src/tasks/task.service.ts b/src/tasks/task.service.ts index b404b8f..40dcc0a 100644 --- a/src/tasks/task.service.ts +++ b/src/tasks/task.service.ts @@ -7,7 +7,12 @@ import { } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma } from '../generated/prisma/client.js'; -import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js'; +import { + DeviceStatus, + Role, + TaskStatus, + UploadAssetType, +} from '../generated/prisma/enums.js'; import { PrismaService } from '../prisma.service.js'; import type { ActorContext } from '../common/actor-context.js'; import { PublishTaskDto } from './dto/publish-task.dto.js'; @@ -29,7 +34,7 @@ export class TaskService { ) {} /** - * 查询当前角色可指定的接收工程师列表。 + * 查询当前角色可见的医院工程师列表。 */ async findAssignableEngineers( actor: ActorContext, @@ -97,6 +102,7 @@ export class TaskService { id: true, status: true, createdAt: true, + completionMaterials: true, hospital: { select: { id: true, @@ -156,6 +162,9 @@ export class TaskService { taskId: item.task.id, status: item.task.status, createdAt: item.task.createdAt, + completionMaterials: Array.isArray(item.task.completionMaterials) + ? item.task.completionMaterials + : [], hospital: item.task.hospital, creator: item.task.creator, engineer: item.task.engineer, @@ -175,7 +184,7 @@ export class TaskService { } /** - * 发布任务:管理员或临床角色创建主任务与明细,并直接指定接收工程师。 + * 发布任务:管理员或临床角色创建主任务与明细,等待本院工程师接收。 */ async publishTask(actor: ActorContext, dto: PublishTaskDto) { this.assertRole(actor, [ @@ -238,18 +247,7 @@ export class TaskService { actor, devices.map((device) => device.patient.hospitalId), ); - - const engineer = await this.prisma.user.findFirst({ - where: { - id: dto.engineerId, - role: Role.ENGINEER, - hospitalId, - }, - select: { id: true }, - }); - if (!engineer) { - throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID); - } + await this.assertNoDuplicateOpenTaskForDevices(deviceIds); const pressureByDeviceId = new Map( devices.map((device) => [device.id, device.currentPressure] as const), @@ -280,9 +278,8 @@ export class TaskService { const task = await this.prisma.task.create({ data: { - status: TaskStatus.ACCEPTED, + status: TaskStatus.PENDING, creatorId: actor.id, - engineerId: engineer.id, hospitalId, items: { create: dto.items.map((item) => ({ @@ -306,10 +303,77 @@ export class TaskService { } /** - * 接收任务:当前流程已停用,统一改为发布时直接指定接收工程师。 + * 接收任务:工程师接收本院待处理任务,任务一旦被接收不可重复抢单。 */ - async acceptTask(_actor: ActorContext, _dto: AcceptTaskDto) { - throw new ForbiddenException(MESSAGES.TASK.ACCEPT_DISABLED); + async acceptTask(actor: ActorContext, dto: AcceptTaskDto) { + this.assertRole(actor, [Role.ENGINEER]); + const hospitalId = this.requireHospitalId(actor); + + const task = await this.prisma.task.findFirst({ + where: { + id: dto.taskId, + hospitalId, + }, + select: { + id: true, + status: true, + engineerId: true, + }, + }); + + if (!task) { + throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND); + } + if (task.status !== TaskStatus.PENDING) { + if (task.engineerId && task.engineerId !== actor.id) { + throw new ConflictException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED); + } + throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING); + } + + const claimResult = await this.prisma.task.updateMany({ + where: { + id: task.id, + hospitalId, + status: TaskStatus.PENDING, + engineerId: null, + }, + data: { + status: TaskStatus.ACCEPTED, + engineerId: actor.id, + }, + }); + + if (claimResult.count !== 1) { + const latestTask = await this.prisma.task.findUnique({ + where: { id: task.id }, + select: { + status: true, + engineerId: true, + }, + }); + if (latestTask?.engineerId && latestTask.engineerId !== actor.id) { + throw new ConflictException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED); + } + throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING); + } + + const acceptedTask = await this.prisma.task.findUnique({ + where: { id: task.id }, + include: { items: true }, + }); + if (!acceptedTask) { + throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND); + } + + await this.eventEmitter.emitAsync('task.accepted', { + taskId: acceptedTask.id, + hospitalId: acceptedTask.hospitalId, + actorId: actor.id, + status: acceptedTask.status, + }); + + return acceptedTask; } /** @@ -318,6 +382,10 @@ export class TaskService { async completeTask(actor: ActorContext, dto: CompleteTaskDto) { this.assertRole(actor, [Role.ENGINEER]); const hospitalId = this.requireHospitalId(actor); + const completionMaterials = await this.normalizeCompletionMaterials( + hospitalId, + dto.completionMaterials, + ); const task = await this.prisma.task.findFirst({ where: { @@ -342,7 +410,10 @@ export class TaskService { const completedTask = await this.prisma.$transaction(async (tx) => { const nextTask = await tx.task.update({ where: { id: task.id }, - data: { status: TaskStatus.COMPLETED }, + data: { + status: TaskStatus.COMPLETED, + completionMaterials, + }, include: { items: true }, }); @@ -369,7 +440,9 @@ export class TaskService { } /** - * 取消任务:任务创建者可将 PENDING/ACCEPTED 任务取消。 + * 取消任务: + * 1. 创建者可将 PENDING/ACCEPTED 任务真正取消为 CANCELLED; + * 2. 接收工程师可将自己已接收任务退回为 PENDING,供其他工程师重新接收。 */ async cancelTask(actor: ActorContext, dto: CancelTaskDto) { this.assertRole(actor, [ @@ -378,6 +451,7 @@ export class TaskService { Role.DOCTOR, Role.DIRECTOR, Role.LEADER, + Role.ENGINEER, ]); const scopedHospitalId = this.resolveScopedHospitalId(actor); @@ -390,6 +464,7 @@ export class TaskService { id: true, status: true, creatorId: true, + engineerId: true, hospitalId: true, }, }); @@ -397,7 +472,33 @@ export class TaskService { if (!task) { throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND); } - if (task.creatorId !== actor.id) { + if (actor.role === Role.ENGINEER) { + if (task.engineerId !== actor.id) { + throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_ASSIGNEE); + } + if (task.status !== TaskStatus.ACCEPTED) { + throw new ConflictException(MESSAGES.TASK.CANCEL_ONLY_PENDING_ACCEPTED); + } + + const releasedTask = await this.prisma.task.update({ + where: { id: task.id }, + data: { + status: TaskStatus.PENDING, + engineerId: null, + }, + include: { items: true }, + }); + + await this.eventEmitter.emitAsync('task.released', { + taskId: releasedTask.id, + hospitalId: releasedTask.hospitalId, + actorId: actor.id, + status: releasedTask.status, + reason: dto.reason?.trim() || null, + }); + + return releasedTask; + } else if (task.creatorId !== actor.id) { throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_CREATOR); } if ( @@ -581,6 +682,74 @@ export class TaskService { return normalizePressureLabel(value, 'targetPressure'); } + /** + * 完成任务凭证标准化:仅允许当前医院下的图片/视频上传资产。 + */ + private async normalizeCompletionMaterials( + hospitalId: number, + materials: CompleteTaskDto['completionMaterials'], + ): Promise { + if (!Array.isArray(materials) || materials.length === 0) { + throw new BadRequestException(MESSAGES.TASK.COMPLETE_MATERIALS_REQUIRED); + } + + const assetIds = Array.from( + new Set( + materials.map((material) => { + const assetId = Number(material.assetId); + if (!Number.isInteger(assetId) || assetId <= 0) { + throw new BadRequestException('assetId 必须是整数'); + } + return assetId; + }), + ), + ); + + const assets = await this.prisma.uploadAsset.findMany({ + where: { + id: { in: assetIds }, + hospitalId, + type: { in: [UploadAssetType.IMAGE, UploadAssetType.VIDEO] }, + }, + select: { + id: true, + type: true, + url: true, + originalName: true, + }, + }); + if (assets.length !== assetIds.length) { + throw new BadRequestException( + MESSAGES.TASK.COMPLETE_MATERIAL_TYPE_INVALID, + ); + } + + const assetMap = new Map(assets.map((asset) => [asset.id, asset])); + + return materials.map((material) => { + const asset = assetMap.get(material.assetId); + if ( + !asset || + (asset.type !== UploadAssetType.IMAGE && + asset.type !== UploadAssetType.VIDEO) + ) { + throw new BadRequestException( + MESSAGES.TASK.COMPLETE_MATERIAL_TYPE_INVALID, + ); + } + + return { + assetId: asset.id, + type: asset.type, + url: asset.url, + name: + typeof material.name === 'string' && material.name.trim() + ? material.name.trim() + : asset.originalName, + }; + }) as Prisma.InputJsonArray; + } + /** * 工程师侧任务流转仍要求明确的院内身份。 */ @@ -590,4 +759,31 @@ export class TaskService { } return actor.hospitalId; } + + /** + * 当前设备存在待接收/已接收任务时,禁止重复发布。 + */ + private async assertNoDuplicateOpenTaskForDevices(deviceIds: number[]) { + if (deviceIds.length === 0) { + return; + } + + const duplicateOpenTask = await this.prisma.taskItem.findFirst({ + where: { + task: { + status: { + in: [TaskStatus.PENDING, TaskStatus.ACCEPTED], + }, + }, + deviceId: { + in: deviceIds, + }, + }, + select: { id: true }, + }); + + if (duplicateOpenTask) { + throw new ConflictException(MESSAGES.TASK.DUPLICATE_DEVICE_OPEN_TASK); + } + } } diff --git a/src/uploads/b-uploads/b-uploads.controller.ts b/src/uploads/b-uploads/b-uploads.controller.ts index d277e20..f1b4664 100644 --- a/src/uploads/b-uploads/b-uploads.controller.ts +++ b/src/uploads/b-uploads/b-uploads.controller.ts @@ -26,7 +26,10 @@ 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 { + ensureUploadDirectories, + resolveUploadTempDir, +} from '../upload-path.util.js'; import { UploadsService } from '../uploads.service.js'; import { diskStorage } from 'multer'; import { extname } from 'node:path'; @@ -65,6 +68,7 @@ export class BUploadsController { Role.DIRECTOR, Role.LEADER, Role.DOCTOR, + Role.ENGINEER, ) @UseInterceptors( FileInterceptor('file', { diff --git a/test/e2e/helpers/e2e-fixtures.helper.ts b/test/e2e/helpers/e2e-fixtures.helper.ts index 10f500d..9358b31 100644 --- a/test/e2e/helpers/e2e-fixtures.helper.ts +++ b/test/e2e/helpers/e2e-fixtures.helper.ts @@ -112,7 +112,12 @@ export async function ensureE2EFixtures( await bootstrapFixturesViaApi(app); } - return loadSeedFixtures(prisma); + try { + return await loadSeedFixtures(prisma); + } catch (error) { + await repairFixturesViaApi(app, prisma); + return loadSeedFixtures(prisma); + } } async function bootstrapFixturesViaApi(app: INestApplication) { @@ -138,14 +143,19 @@ async function bootstrapFixturesViaApi(app: INestApplication) { { name: FIXTURE_NAMES.hospitalB }, ); - const hospitalAdminA = await createWithToken(server, systemAdminToken, '/users', { - name: 'Seed Hospital Admin A', - phone: E2E_SEED_CREDENTIALS[Role.HOSPITAL_ADMIN].phone, - password: E2E_SEED_PASSWORD, - role: Role.HOSPITAL_ADMIN, - hospitalId: hospitalA.id, - openId: OPEN_IDS.hospitalAdminA, - }); + const hospitalAdminA = await createWithToken( + server, + systemAdminToken, + '/users', + { + name: 'Seed Hospital Admin A', + phone: E2E_SEED_CREDENTIALS[Role.HOSPITAL_ADMIN].phone, + password: E2E_SEED_PASSWORD, + role: Role.HOSPITAL_ADMIN, + hospitalId: hospitalA.id, + openId: OPEN_IDS.hospitalAdminA, + }, + ); await createWithToken(server, systemAdminToken, '/users', { name: 'Seed Hospital Admin B', phone: EXTRA_PHONES.hospitalAdminB, @@ -254,15 +264,20 @@ async function bootstrapFixturesViaApi(app: INestApplication) { }, ); - const directorA = await createWithToken(server, hospitalAdminAToken, '/users', { - name: 'Seed Director A', - phone: E2E_SEED_CREDENTIALS[Role.DIRECTOR].phone, - password: E2E_SEED_PASSWORD, - role: Role.DIRECTOR, - hospitalId: hospitalA.id, - departmentId: departmentA1.id, - openId: OPEN_IDS.directorA, - }); + const directorA = await createWithToken( + server, + hospitalAdminAToken, + '/users', + { + name: 'Seed Director A', + phone: E2E_SEED_CREDENTIALS[Role.DIRECTOR].phone, + password: E2E_SEED_PASSWORD, + role: Role.DIRECTOR, + hospitalId: hospitalA.id, + departmentId: departmentA1.id, + openId: OPEN_IDS.directorA, + }, + ); await createWithToken(server, hospitalAdminAToken, '/users', { name: 'Seed Leader A', phone: E2E_SEED_CREDENTIALS[Role.LEADER].phone, @@ -283,26 +298,36 @@ async function bootstrapFixturesViaApi(app: INestApplication) { groupId: groupA1.id, openId: OPEN_IDS.doctorA, }); - const doctorA2 = await createWithToken(server, hospitalAdminAToken, '/users', { - name: 'Seed Doctor A2', - phone: EXTRA_PHONES.doctorA2, - password: E2E_SEED_PASSWORD, - role: Role.DOCTOR, - hospitalId: hospitalA.id, - departmentId: departmentA1.id, - groupId: groupA1.id, - openId: OPEN_IDS.doctorA2, - }); - const doctorA3 = await createWithToken(server, hospitalAdminAToken, '/users', { - name: 'Seed Doctor A3', - phone: EXTRA_PHONES.doctorA3, - password: E2E_SEED_PASSWORD, - role: Role.DOCTOR, - hospitalId: hospitalA.id, - departmentId: departmentA2.id, - groupId: groupA2.id, - openId: OPEN_IDS.doctorA3, - }); + const doctorA2 = await createWithToken( + server, + hospitalAdminAToken, + '/users', + { + name: 'Seed Doctor A2', + phone: EXTRA_PHONES.doctorA2, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: hospitalA.id, + departmentId: departmentA1.id, + groupId: groupA1.id, + openId: OPEN_IDS.doctorA2, + }, + ); + const doctorA3 = await createWithToken( + server, + hospitalAdminAToken, + '/users', + { + name: 'Seed Doctor A3', + phone: EXTRA_PHONES.doctorA3, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: hospitalA.id, + departmentId: departmentA2.id, + groupId: groupA2.id, + openId: OPEN_IDS.doctorA3, + }, + ); const doctorB = await createWithToken(server, hospitalAdminBToken, '/users', { name: 'Seed Doctor B', phone: EXTRA_PHONES.doctorB, @@ -324,8 +349,8 @@ async function bootstrapFixturesViaApi(app: INestApplication) { modelCode: FIXTURE_NAMES.adjustableCatalog, manufacturer: 'Seed MedTech', name: 'Seed 可调压分流阀', + isValve: true, pressureLevels: ['0.5', '1', '1.5'], - isPressureAdjustable: true, notes: 'Seed 全局可调压目录样例', }, ); @@ -333,8 +358,8 @@ async function bootstrapFixturesViaApi(app: INestApplication) { modelCode: FIXTURE_NAMES.highPressureCatalog, manufacturer: 'Seed MedTech', name: 'Seed 高压挡位阀', + isValve: true, pressureLevels: ['10', '20', '30'], - isPressureAdjustable: true, notes: 'Seed 高压挡位目录样例', }); @@ -438,33 +463,38 @@ async function bootstrapFixturesViaApi(app: INestApplication) { }, ); - const patientA2 = await createWithToken(server, doctorA2Token, '/b/patients', { - name: 'Seed Patient A2', - inpatientNo: 'ZYH-A-0002', - projectName: '脑积水随访项目-A', - phone: '13800002002', - idCard: '110101199002020022', - doctorId: doctorA2.id, - initialSurgery: { - surgeryDate: '2025-12-15T08:00:00.000Z', - surgeryName: '脑室腹腔分流术', - preOpPressure: 20, - primaryDisease: '肿瘤相关脑积水', - hydrocephalusTypes: ['梗阻性'], - devices: [ - { - implantCatalogId: adjustableCatalog.id, - shuntMode: 'VPS', - proximalPunctureAreas: ['枕角'], - valvePlacementSites: ['胸前'], - distalShuntDirection: '腹腔', - initialPressure: '1', - implantNotes: 'Seed A2 当前在用设备', - labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg', - }, - ], + const patientA2 = await createWithToken( + server, + doctorA2Token, + '/b/patients', + { + name: 'Seed Patient A2', + inpatientNo: 'ZYH-A-0002', + projectName: '脑积水随访项目-A', + phone: '13800002002', + idCard: '110101199002020022', + doctorId: doctorA2.id, + initialSurgery: { + surgeryDate: '2025-12-15T08:00:00.000Z', + surgeryName: '脑室腹腔分流术', + preOpPressure: 20, + primaryDisease: '肿瘤相关脑积水', + hydrocephalusTypes: ['梗阻性'], + devices: [ + { + implantCatalogId: adjustableCatalog.id, + shuntMode: 'VPS', + proximalPunctureAreas: ['枕角'], + valvePlacementSites: ['胸前'], + distalShuntDirection: '腹腔', + initialPressure: '1', + implantNotes: 'Seed A2 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg', + }, + ], + }, }, - }); + ); await createWithToken(server, doctorA3Token, '/b/patients', { name: 'Seed Patient A3', @@ -533,7 +563,6 @@ async function bootstrapFixturesViaApi(app: INestApplication) { doctorAToken, '/b/tasks/publish', { - engineerId: engineerA.id, items: [ { deviceId: deviceA1Id, @@ -543,12 +572,15 @@ async function bootstrapFixturesViaApi(app: INestApplication) { }, ); + await createWithToken(server, engineerAToken, '/b/tasks/accept', { + taskId: publishedA.id, + }); + await createWithToken(server, engineerAToken, '/b/tasks/complete', { taskId: publishedA.id, }); await createWithToken(server, doctorBToken, '/b/tasks/publish', { - engineerId: engineerB.id, items: [ { deviceId: deviceB1Id, @@ -561,6 +593,325 @@ async function bootstrapFixturesViaApi(app: INestApplication) { void patientA2; } +async function repairFixturesViaApi( + app: INestApplication, + prisma: PrismaService, +) { + const server = app.getHttpServer(); + const systemAdminToken = await loginByCredential(server, { + phone: E2E_SEED_CREDENTIALS[Role.SYSTEM_ADMIN].phone, + password: E2E_SEED_PASSWORD, + role: Role.SYSTEM_ADMIN, + }); + + const hospitalAdminA = await requireUserScope( + prisma, + OPEN_IDS.hospitalAdminA, + ); + const doctorA = await requireUserScope(prisma, OPEN_IDS.doctorA); + const doctorA2 = await requireUserScope(prisma, OPEN_IDS.doctorA2); + const doctorA3 = await requireUserScope(prisma, OPEN_IDS.doctorA3); + const doctorB = await requireUserScope(prisma, OPEN_IDS.doctorB); + + if ( + hospitalAdminA.hospitalId == null || + doctorA.hospitalId == null || + doctorB.hospitalId == null || + doctorA.id == null || + doctorA2.id == null || + doctorA3.id == null || + doctorB.id == null + ) { + throw new NotFoundException('Seed user scope is incomplete'); + } + + const adjustableCatalog = await ensureCatalogViaApi( + prisma, + server, + systemAdminToken, + { + modelCode: FIXTURE_NAMES.adjustableCatalog, + manufacturer: 'Seed MedTech', + name: 'Seed 可调压分流阀', + isValve: true, + pressureLevels: ['0.5', '1', '1.5'], + notes: 'Seed 全局可调压目录样例', + }, + ); + await ensureCatalogViaApi(prisma, server, systemAdminToken, { + modelCode: FIXTURE_NAMES.highPressureCatalog, + manufacturer: 'Seed MedTech', + name: 'Seed 高压挡位阀', + isValve: true, + pressureLevels: ['10', '20', '30'], + notes: 'Seed 高压挡位目录样例', + }); + + const doctorAToken = await loginByCredential(server, { + phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: doctorA.hospitalId, + }); + const doctorA2Token = await loginByCredential(server, { + phone: EXTRA_PHONES.doctorA2, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: doctorA.hospitalId, + }); + const doctorA3Token = await loginByCredential(server, { + phone: EXTRA_PHONES.doctorA3, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: doctorA.hospitalId, + }); + const doctorBToken = await loginByCredential(server, { + phone: EXTRA_PHONES.doctorB, + password: E2E_SEED_PASSWORD, + role: Role.DOCTOR, + hospitalId: doctorB.hospitalId, + }); + const engineerAToken = await loginByCredential(server, { + phone: E2E_SEED_CREDENTIALS[Role.ENGINEER].phone, + password: E2E_SEED_PASSWORD, + role: Role.ENGINEER, + hospitalId: doctorA.hospitalId, + }); + + let patientA1Id = await findPatientId( + prisma, + doctorA.hospitalId, + SHARED_PATIENT_IDENTITY.phone, + SHARED_PATIENT_IDENTITY.idCard, + ); + if (!patientA1Id) { + const patientA1 = await createWithToken( + server, + doctorAToken, + '/b/patients', + { + name: 'Seed Patient A1', + inpatientNo: 'ZYH-A-0001', + projectName: '脑积水随访项目-A', + phone: SHARED_PATIENT_IDENTITY.phone, + idCard: SHARED_PATIENT_IDENTITY.idCard, + doctorId: doctorA.id, + initialSurgery: { + surgeryDate: '2024-06-01T08:00:00.000Z', + surgeryName: '首次脑室腹腔分流术', + preOpPressure: 24, + primaryDisease: '先天性脑积水', + hydrocephalusTypes: ['交通性'], + notes: '首台手术', + devices: [ + { + implantCatalogId: adjustableCatalog.id, + shuntMode: 'VPS', + proximalPunctureAreas: ['额角'], + valvePlacementSites: ['耳后'], + distalShuntDirection: '腹腔', + initialPressure: '1', + implantNotes: 'Seed A1 弃用历史设备', + labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg', + }, + ], + }, + }, + ); + + const oldDeviceId = patientA1.devices?.[0]?.id; + if (!oldDeviceId) { + throw new Error('failed to create seed old device'); + } + + await createWithToken( + server, + doctorAToken, + `/b/patients/${patientA1.id}/surgeries`, + { + surgeryDate: '2025-09-10T08:00:00.000Z', + surgeryName: '分流系统翻修术', + preOpPressure: 18, + primaryDisease: '分流功能障碍', + hydrocephalusTypes: ['交通性', '高压性'], + previousShuntSurgeryDate: '2024-06-01T08:00:00.000Z', + preOpMaterials: [ + { + type: 'IMAGE', + url: 'https://seed.example.com/a1-ct-preop.png', + name: 'Seed A1 术前 CT', + }, + ], + notes: '二次手术,保留原设备历史', + abandonedDeviceIds: [oldDeviceId], + devices: [ + { + implantCatalogId: adjustableCatalog.id, + shuntMode: 'VPS', + proximalPunctureAreas: ['额角'], + valvePlacementSites: ['耳后'], + distalShuntDirection: '腹腔', + initialPressure: '1', + implantNotes: 'Seed A1 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg', + }, + ], + }, + ); + patientA1Id = patientA1.id; + } + + if ( + !(await findPatientId( + prisma, + doctorA.hospitalId, + '13800002002', + '110101199002020022', + )) + ) { + await createWithToken(server, doctorA2Token, '/b/patients', { + name: 'Seed Patient A2', + inpatientNo: 'ZYH-A-0002', + projectName: '脑积水随访项目-A', + phone: '13800002002', + idCard: '110101199002020022', + doctorId: doctorA2.id, + initialSurgery: { + surgeryDate: '2025-12-15T08:00:00.000Z', + surgeryName: '脑室腹腔分流术', + preOpPressure: 20, + primaryDisease: '肿瘤相关脑积水', + hydrocephalusTypes: ['梗阻性'], + devices: [ + { + implantCatalogId: adjustableCatalog.id, + shuntMode: 'VPS', + proximalPunctureAreas: ['枕角'], + valvePlacementSites: ['胸前'], + distalShuntDirection: '腹腔', + initialPressure: '1', + implantNotes: 'Seed A2 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg', + }, + ], + }, + }); + } + + if ( + !(await findPatientId( + prisma, + doctorA.hospitalId, + '13800002003', + '110101199003030033', + )) + ) { + await createWithToken(server, doctorA3Token, '/b/patients', { + name: 'Seed Patient A3', + inpatientNo: 'ZYH-A-0003', + projectName: '脑积水随访项目-A', + phone: '13800002003', + idCard: '110101199003030033', + doctorId: doctorA3.id, + initialSurgery: { + surgeryDate: '2025-11-20T08:00:00.000Z', + surgeryName: '脑室腹腔分流术', + preOpPressure: 21, + primaryDisease: '外伤后脑积水', + hydrocephalusTypes: ['交通性'], + devices: [ + { + implantCatalogId: adjustableCatalog.id, + shuntMode: 'LPS', + proximalPunctureAreas: ['腰穿'], + valvePlacementSites: ['腰背部'], + distalShuntDirection: '腹腔', + initialPressure: '1', + implantNotes: 'Seed A3 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg', + }, + ], + }, + }); + } + + if ( + !(await findPatientId( + prisma, + doctorB.hospitalId, + SHARED_PATIENT_IDENTITY.phone, + SHARED_PATIENT_IDENTITY.idCard, + )) + ) { + await createWithToken(server, doctorBToken, '/b/patients', { + name: 'Seed Patient B1', + inpatientNo: 'ZYH-B-0001', + projectName: '脑积水随访项目-B', + phone: SHARED_PATIENT_IDENTITY.phone, + idCard: SHARED_PATIENT_IDENTITY.idCard, + doctorId: doctorB.id, + initialSurgery: { + surgeryDate: '2025-10-05T08:00:00.000Z', + surgeryName: '脑室腹腔分流术', + preOpPressure: 23, + primaryDisease: '出血后脑积水', + hydrocephalusTypes: ['高压性'], + devices: [ + { + implantCatalogId: adjustableCatalog.id, + shuntMode: 'VPS', + proximalPunctureAreas: ['额角'], + valvePlacementSites: ['耳后'], + distalShuntDirection: '腹腔', + initialPressure: '1', + implantNotes: 'Seed B1 当前在用设备', + labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg', + }, + ], + }, + }); + } + + const deviceA1Id = await requireDeviceId(prisma, 'Seed A1 当前在用设备'); + const deviceB1Id = await requireDeviceId(prisma, 'Seed B1 当前在用设备'); + + if (!(await hasTaskItemForDevice(prisma, deviceA1Id))) { + const publishedA = await createWithToken( + server, + doctorAToken, + '/b/tasks/publish', + { + items: [ + { + deviceId: deviceA1Id, + targetPressure: '1.5', + }, + ], + }, + ); + + await createWithToken(server, engineerAToken, '/b/tasks/accept', { + taskId: publishedA.id, + }); + await createWithToken(server, engineerAToken, '/b/tasks/complete', { + taskId: publishedA.id, + }); + } + + if (!(await hasTaskItemForDevice(prisma, deviceB1Id))) { + await createWithToken(server, doctorBToken, '/b/tasks/publish', { + items: [ + { + deviceId: deviceB1Id, + targetPressure: '1.5', + }, + ], + }); + } + + void patientA1Id; +} + async function bootstrapDictionaries( server: ReturnType, systemAdminToken: string, @@ -693,6 +1044,56 @@ async function requireCatalogId( return catalog.id; } +async function findPatientId( + prisma: PrismaService, + hospitalId: number, + phone: string, + idCard: string, +) { + const patient = await prisma.patient.findFirst({ + where: { hospitalId, phone, idCard }, + select: { id: true }, + }); + return patient?.id ?? null; +} + +async function hasTaskItemForDevice(prisma: PrismaService, deviceId: number) { + const taskItem = await prisma.taskItem.findFirst({ + where: { deviceId }, + select: { id: true }, + }); + return Boolean(taskItem); +} + +async function ensureCatalogViaApi( + prisma: PrismaService, + server: ReturnType, + systemAdminToken: string, + payload: { + modelCode: string; + manufacturer: string; + name: string; + isValve: boolean; + pressureLevels: string[]; + notes: string; + }, +) { + const existing = await prisma.implantCatalog.findFirst({ + where: { modelCode: payload.modelCode }, + select: { id: true }, + }); + if (existing) { + return existing; + } + + return createWithToken( + server, + systemAdminToken, + '/b/devices/catalogs', + payload, + ); +} + async function requireDeviceId( prisma: PrismaService, implantNotes: string, @@ -727,7 +1128,10 @@ export async function loadSeedFixtures( prisma: PrismaService, ): Promise { const systemAdmin = await requireUserScope(prisma, OPEN_IDS.systemAdmin); - const hospitalAdminA = await requireUserScope(prisma, OPEN_IDS.hospitalAdminA); + const hospitalAdminA = await requireUserScope( + prisma, + OPEN_IDS.hospitalAdminA, + ); const directorA = await requireUserScope(prisma, OPEN_IDS.directorA); const leaderA = await requireUserScope(prisma, OPEN_IDS.leaderA); const doctorA = await requireUserScope(prisma, OPEN_IDS.doctorA); diff --git a/test/e2e/specs/devices.e2e-spec.ts b/test/e2e/specs/devices.e2e-spec.ts index 31ffe7b..7ebfae1 100644 --- a/test/e2e/specs/devices.e2e-spec.ts +++ b/test/e2e/specs/devices.e2e-spec.ts @@ -115,13 +115,18 @@ describe('BDevicesController (e2e)', () => { modelCode: uniqueSeedValue('catalog').toUpperCase(), manufacturer: 'Global Vendor', name: '全局可调压阀', - isPressureAdjustable: true, + isValve: true, pressureLevels: ['10.0', '20', '30.0'], notes: '测试全局目录', }); expectSuccessEnvelope(createResponse, 201); - expect(createResponse.body.data.pressureLevels).toEqual(['10', '20', '30']); + expect(createResponse.body.data.isValve).toBe(true); + expect(createResponse.body.data.pressureLevels).toEqual([ + '10', + '20', + '30', + ]); const updateResponse = await request(ctx.app.getHttpServer()) .patch(`/b/devices/catalogs/${createResponse.body.data.id}`) @@ -133,6 +138,7 @@ describe('BDevicesController (e2e)', () => { expectSuccessEnvelope(updateResponse, 200); expect(updateResponse.body.data.name).toBe('全局可调压阀-更新版'); + expect(updateResponse.body.data.isValve).toBe(true); expect(updateResponse.body.data.pressureLevels).toEqual([ '0.5', '1', @@ -147,6 +153,24 @@ describe('BDevicesController (e2e)', () => { expect(deleteResponse.body.data.id).toBe(createResponse.body.data.id); }); + it('成功:SYSTEM_ADMIN 可新增非阀门目录,且不会保存压力挡位', async () => { + const response = await request(ctx.app.getHttpServer()) + .post('/b/devices/catalogs') + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) + .send({ + modelCode: uniqueSeedValue('tube').toUpperCase(), + manufacturer: 'Global Vendor', + name: '腹腔管', + isValve: false, + pressureLevels: ['1', '2'], + }); + + expectSuccessEnvelope(response, 201); + expect(response.body.data.isValve).toBe(false); + expect(response.body.data.isPressureAdjustable).toBe(false); + expect(response.body.data.pressureLevels).toEqual([]); + }); + it('角色矩阵:仅 SYSTEM_ADMIN 可维护全局植入物目录', async () => { await assertRoleMatrix({ name: 'POST /b/devices/catalogs role matrix', @@ -167,7 +191,7 @@ describe('BDevicesController (e2e)', () => { modelCode: uniqueSeedValue('catalog-role').toUpperCase(), manufacturer: 'Role Matrix Vendor', name: '角色矩阵目录', - isPressureAdjustable: true, + isValve: true, pressureLevels: ['10', '20'], }), sendWithoutToken: async () => diff --git a/test/e2e/specs/patients.e2e-spec.ts b/test/e2e/specs/patients.e2e-spec.ts index 773637f..3870642 100644 --- a/test/e2e/specs/patients.e2e-spec.ts +++ b/test/e2e/specs/patients.e2e-spec.ts @@ -1,3 +1,4 @@ +import sharp from 'sharp'; import request from 'supertest'; import { DeviceStatus, Role } from '../../../src/generated/prisma/enums.js'; import { @@ -22,15 +23,45 @@ function uniqueIdCard() { describe('Patients Controllers (e2e)', () => { let ctx: E2EContext; + let samplePngBuffer: Buffer; beforeAll(async () => { ctx = await createE2EContext(); + samplePngBuffer = await sharp({ + create: { + width: 24, + height: 24, + channels: 3, + background: { r: 20, g: 60, b: 120 }, + }, + }) + .png() + .toBuffer(); }); afterAll(async () => { await closeE2EContext(ctx); }); + async function uploadEngineerProof() { + const response = await request(ctx.app.getHttpServer()) + .post('/b/uploads') + .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) + .attach('file', samplePngBuffer, { + filename: 'patient-task-proof.png', + contentType: 'image/png', + }); + + expectSuccessEnvelope(response, 201); + return response.body.data as { + id: number; + type: 'IMAGE' | 'VIDEO'; + url: string; + originalName?: string; + fileName?: string; + }; + } + describe('GET /b/patients', () => { it('成功:按角色返回正确可见性范围', async () => { const systemAdminResponse = await request(ctx.app.getHttpServer()) @@ -292,7 +323,6 @@ describe('Patients Controllers (e2e)', () => { .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ - engineerId: ctx.fixtures.users.engineerAId, items: [ { deviceId: oldDeviceId, @@ -302,10 +332,27 @@ describe('Patients Controllers (e2e)', () => { }); expectSuccessEnvelope(publishResponse, 201); + const acceptResponse = await request(ctx.app.getHttpServer()) + .post('/b/tasks/accept') + .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) + .send({ taskId: publishResponse.body.data.id }); + expectSuccessEnvelope(acceptResponse, 201); + const proof = await uploadEngineerProof(); + const completeResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) - .send({ taskId: publishResponse.body.data.id }); + .send({ + taskId: publishResponse.body.data.id, + completionMaterials: [ + { + assetId: proof.id, + type: proof.type, + url: proof.url, + name: proof.originalName || proof.fileName || '调压完成照片', + }, + ], + }); expectSuccessEnvelope(completeResponse, 201); const surgeryResponse = await request(ctx.app.getHttpServer()) diff --git a/test/e2e/specs/tasks.e2e-spec.ts b/test/e2e/specs/tasks.e2e-spec.ts index bb65b07..393f752 100644 --- a/test/e2e/specs/tasks.e2e-spec.ts +++ b/test/e2e/specs/tasks.e2e-spec.ts @@ -1,3 +1,4 @@ +import sharp from 'sharp'; import request from 'supertest'; import { Role, TaskStatus } from '../../../src/generated/prisma/enums.js'; import { @@ -9,30 +10,54 @@ import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js'; import { expectErrorEnvelope, expectSuccessEnvelope, + uniquePhone, + uniqueSeedValue, } from '../helpers/e2e-http.helper.js'; +function uniqueIdCard() { + const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}` + .replace(/\D/g, '') + .slice(-4); + return `11010119990101${suffix.padStart(4, '0')}`; +} + describe('BTasksController (e2e)', () => { let ctx: E2EContext; + let samplePngBuffer: Buffer; + let doctorBToken = ''; beforeAll(async () => { ctx = await createE2EContext(); + samplePngBuffer = await sharp({ + create: { + width: 32, + height: 32, + channels: 3, + background: { r: 42, g: 78, b: 126 }, + }, + }) + .png() + .toBuffer(); + doctorBToken = await loginByUser( + ctx.fixtures.users.doctorBId, + Role.DOCTOR, + ctx.fixtures.hospitalBId, + ); }); afterAll(async () => { await closeE2EContext(ctx); }); - async function publishAssignedTask( + async function publishPendingTask( deviceId: number, targetPressure: string, actorToken = ctx.tokens[Role.DOCTOR], - engineerId = ctx.fixtures.users.engineerAId, ) { const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${actorToken}`) .send({ - engineerId, items: [ { deviceId, @@ -45,11 +70,131 @@ describe('BTasksController (e2e)', () => { return response.body.data as { id: number; status: TaskStatus; - engineerId: number; + engineerId: number | null; hospitalId: number; }; } + async function acceptPendingTask( + taskId: number, + actorToken = ctx.tokens[Role.ENGINEER], + ) { + const response = await request(ctx.app.getHttpServer()) + .post('/b/tasks/accept') + .set('Authorization', `Bearer ${actorToken}`) + .send({ taskId }); + + expectSuccessEnvelope(response, 201); + return response.body.data as { + id: number; + status: TaskStatus; + engineerId: number; + }; + } + + async function loginByUser(userId: number, role: Role, hospitalId: number) { + const user = await ctx.prisma.user.findUnique({ + where: { id: userId }, + select: { phone: true }, + }); + expect(user?.phone).toBeTruthy(); + + const response = await request(ctx.app.getHttpServer()) + .post('/auth/login') + .send({ + phone: user?.phone, + password: 'Seed@1234', + role, + hospitalId, + }); + + expectSuccessEnvelope(response, 201); + return response.body.data.accessToken as string; + } + + async function createAdjustableDevices(options?: { + actorToken?: string; + doctorId?: number; + count?: number; + patientName?: string; + projectName?: string; + }) { + const { + actorToken = ctx.tokens[Role.DOCTOR], + doctorId = ctx.fixtures.users.doctorAId, + count = 1, + patientName = '调压测试患者', + projectName = '调压测试项目', + } = options ?? {}; + + const response = await request(ctx.app.getHttpServer()) + .post('/b/patients') + .set('Authorization', `Bearer ${actorToken}`) + .send({ + name: `${patientName}-${uniqueSeedValue('name')}`, + inpatientNo: uniqueSeedValue('zyh'), + projectName, + phone: uniquePhone(), + idCard: uniqueIdCard(), + doctorId, + initialSurgery: { + surgeryDate: '2026-03-20T08:00:00.000Z', + surgeryName: '调压测试手术', + preOpPressure: 18, + primaryDisease: '交通性脑积水', + hydrocephalusTypes: ['交通性'], + devices: Array.from({ length: count }, (_, index) => ({ + implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, + shuntMode: 'VPS', + proximalPunctureAreas: [index % 2 === 0 ? '额角' : '枕角'], + valvePlacementSites: [index % 2 === 0 ? '耳后' : '胸前'], + distalShuntDirection: '腹腔', + initialPressure: index % 2 === 0 ? '1' : '1.5', + implantNotes: uniqueSeedValue(`task-device-${index + 1}`), + })), + }, + }); + + expectSuccessEnvelope(response, 201); + return response.body.data.devices as Array<{ id: number }>; + } + + async function uploadEngineerProof(actorToken = ctx.tokens[Role.ENGINEER]) { + const response = await request(ctx.app.getHttpServer()) + .post('/b/uploads') + .set('Authorization', `Bearer ${actorToken}`) + .attach('file', samplePngBuffer, { + filename: 'task-proof.png', + contentType: 'image/png', + }); + + expectSuccessEnvelope(response, 201); + return response.body.data as { + id: number; + type: 'IMAGE' | 'VIDEO'; + url: string; + originalName?: string; + fileName?: string; + }; + } + + function buildCompletionPayload( + taskId: number, + asset: Awaited>, + ) { + return { + taskId, + completionMaterials: [ + { + assetId: asset.id, + type: asset.type, + url: asset.url, + name: asset.originalName || asset.fileName || '调压完成照片', + }, + ], + }; + } + describe('GET /b/tasks/engineers', () => { it('成功:DOCTOR 可查看本院可选工程师', async () => { const response = await request(ctx.app.getHttpServer()) @@ -94,6 +239,24 @@ describe('BTasksController (e2e)', () => { describe('GET /b/tasks', () => { it('成功:SYSTEM_ADMIN 可查看跨医院调压记录', async () => { + const [deviceA] = await createAdjustableDevices(); + const [deviceB] = await createAdjustableDevices({ + actorToken: doctorBToken, + doctorId: ctx.fixtures.users.doctorBId, + patientName: 'B院调压患者', + }); + + await publishPendingTask( + deviceA.id, + '1.5', + ctx.tokens[Role.SYSTEM_ADMIN], + ); + await publishPendingTask( + deviceB.id, + '1.5', + ctx.tokens[Role.SYSTEM_ADMIN], + ); + const response = await request(ctx.app.getHttpServer()) .get('/b/tasks') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) @@ -106,12 +269,9 @@ describe('BTasksController (e2e)', () => { response.body.data.list.every( (item: { creator?: { id?: number; name?: string }; - engineer?: { id?: number; name?: string }; + engineer?: { id?: number; name?: string } | null; }) => - Number.isInteger(item.creator?.id) && - Boolean(item.creator?.name) && - Number.isInteger(item.engineer?.id) && - Boolean(item.engineer?.name), + Number.isInteger(item.creator?.id) && Boolean(item.creator?.name), ), ).toBe(true); @@ -158,63 +318,46 @@ describe('BTasksController (e2e)', () => { }); describe('POST /b/tasks/publish', () => { - it('成功:DOCTOR 发布任务时必须直接指定接收工程师', async () => { + it('成功:DOCTOR 发布任务后进入待接收状态', async () => { + const [device] = await createAdjustableDevices(); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ - engineerId: ctx.fixtures.users.engineerAId, items: [ { - deviceId: ctx.fixtures.devices.deviceA2Id, + deviceId: device.id, targetPressure: '1.5', }, ], }); expectSuccessEnvelope(response, 201); - expect(response.body.data.status).toBe(TaskStatus.ACCEPTED); - expect(response.body.data.engineerId).toBe( - ctx.fixtures.users.engineerAId, - ); + expect(response.body.data.status).toBe(TaskStatus.PENDING); + expect(response.body.data.engineerId).toBeNull(); }); it('成功:SYSTEM_ADMIN 可按设备自动归院发布任务', async () => { - const task = await publishAssignedTask( - ctx.fixtures.devices.deviceA1Id, + const [device] = await createAdjustableDevices(); + const task = await publishPendingTask( + device.id, '1.5', ctx.tokens[Role.SYSTEM_ADMIN], ); - expect(task.status).toBe(TaskStatus.ACCEPTED); + expect(task.status).toBe(TaskStatus.PENDING); expect(task.hospitalId).toBe(ctx.fixtures.hospitalAId); }); - it('失败:未指定接收工程师返回 400', async () => { - const response = await request(ctx.app.getHttpServer()) - .post('/b/tasks/publish') - .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) - .send({ - items: [ - { - deviceId: ctx.fixtures.devices.deviceA2Id, - targetPressure: '1.5', - }, - ], - }); - - expectErrorEnvelope(response, 400, 'engineerId 必须是整数'); - }); - it('失败:可调压设备使用非法挡位返回 400', async () => { + const [device] = await createAdjustableDevices(); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ - engineerId: ctx.fixtures.users.engineerAId, items: [ { - deviceId: ctx.fixtures.devices.deviceA2Id, + deviceId: device.id, targetPressure: '2', }, ], @@ -228,7 +371,6 @@ describe('BTasksController (e2e)', () => { .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ - engineerId: ctx.fixtures.users.engineerAId, items: [ { deviceId: ctx.fixtures.devices.deviceB1Id, @@ -240,6 +382,47 @@ describe('BTasksController (e2e)', () => { expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在'); }); + it('失败:已有待处理任务的设备不可重复发布', async () => { + const [device] = await createAdjustableDevices(); + await publishPendingTask(device.id, '1.5'); + + const duplicateResponse = await request(ctx.app.getHttpServer()) + .post('/b/tasks/publish') + .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) + .send({ + items: [ + { + deviceId: device.id, + targetPressure: '1', + }, + ], + }); + + expectErrorEnvelope(duplicateResponse, 409, '该设备已有待处理调压任务'); + }); + + it('成功:同一患者另一台设备无任务时仍可发布', async () => { + const createdDevices = await createAdjustableDevices({ count: 2 }); + expect(createdDevices).toHaveLength(2); + + await publishPendingTask(createdDevices[0].id, '1.5'); + + const response = await request(ctx.app.getHttpServer()) + .post('/b/tasks/publish') + .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) + .send({ + items: [ + { + deviceId: createdDevices[1].id, + targetPressure: '1', + }, + ], + }); + + expectSuccessEnvelope(response, 201); + expect(response.body.data.status).toBe(TaskStatus.PENDING); + }); + it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/tasks/publish role matrix', @@ -264,25 +447,56 @@ describe('BTasksController (e2e)', () => { }); describe('POST /b/tasks/accept', () => { - it('失败:ENGINEER 接收接口已停用,返回 403', async () => { - const task = await publishAssignedTask( - ctx.fixtures.devices.deviceA2Id, - '1.5', - ); + it('成功:ENGINEER 可接收本院待处理任务', async () => { + const [device] = await createAdjustableDevices(); + const task = await publishPendingTask(device.id, '1.5'); + + const acceptedTask = await acceptPendingTask(task.id); + expect(acceptedTask.status).toBe(TaskStatus.ACCEPTED); + expect(acceptedTask.engineerId).toBe(ctx.fixtures.users.engineerAId); + }); + + it('失败:跨院工程师不能接收任务', async () => { + const [device] = await createAdjustableDevices(); + const task = await publishPendingTask(device.id, '1.5'); + const engineerB = await ctx.prisma.user.findUnique({ + where: { id: ctx.fixtures.users.engineerBId }, + select: { phone: true }, + }); + expect(engineerB?.phone).toBeTruthy(); + + const loginResponse = await request(ctx.app.getHttpServer()) + .post('/auth/login') + .send({ + phone: engineerB?.phone, + password: 'Seed@1234', + role: Role.ENGINEER, + hospitalId: ctx.fixtures.hospitalBId, + }); + expectSuccessEnvelope(loginResponse, 201); + + const response = await request(ctx.app.getHttpServer()) + .post('/b/tasks/accept') + .set('Authorization', `Bearer ${loginResponse.body.data.accessToken}`) + .send({ taskId: task.id }); + + expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院'); + }); + + it('状态机失败:已被接收的任务不可再次接收', async () => { + const [device] = await createAdjustableDevices(); + const task = await publishPendingTask(device.id, '1.5'); + await acceptPendingTask(task.id); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/accept') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send({ taskId: task.id }); - expectErrorEnvelope( - response, - 403, - '当前流程不支持工程师接收,请由创建人直接指定接收工程师', - ); + expectErrorEnvelope(response, 409, '仅待接收任务可执行接收'); }); - it('角色矩阵:接收接口对所有角色都不可用,未登录 401', async () => { + it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/tasks/accept role matrix', tokens: ctx.tokens, @@ -292,7 +506,7 @@ describe('BTasksController (e2e)', () => { [Role.DIRECTOR]: 403, [Role.LEADER]: 403, [Role.DOCTOR]: 403, - [Role.ENGINEER]: 403, + [Role.ENGINEER]: 404, }, sendAsRole: async (_role, token) => request(ctx.app.getHttpServer()) @@ -308,58 +522,83 @@ describe('BTasksController (e2e)', () => { }); describe('POST /b/tasks/complete', () => { - it('成功:ENGINEER 可直接完成已指派任务并同步设备压力', async () => { + it('成功:ENGINEER 可完成自己已接收的任务并同步设备压力', async () => { const targetPressure = '1.5'; - const task = await publishAssignedTask( - ctx.fixtures.devices.deviceA1Id, - targetPressure, - ); + const [device] = await createAdjustableDevices(); + const task = await publishPendingTask(device.id, targetPressure); + await acceptPendingTask(task.id); + const proof = await uploadEngineerProof(); const completeResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) - .send({ taskId: task.id }); + .send(buildCompletionPayload(task.id, proof)); expectSuccessEnvelope(completeResponse, 201); expect(completeResponse.body.data.status).toBe(TaskStatus.COMPLETED); + expect(completeResponse.body.data.completionMaterials).toEqual([ + expect.objectContaining({ + assetId: proof.id, + type: 'IMAGE', + url: proof.url, + }), + ]); - const device = await ctx.prisma.device.findUnique({ - where: { id: ctx.fixtures.devices.deviceA1Id }, + const updatedDevice = await ctx.prisma.device.findUnique({ + where: { id: device.id }, select: { currentPressure: true }, }); - expect(device?.currentPressure).toBe(targetPressure); + expect(updatedDevice?.currentPressure).toBe(targetPressure); }); it('失败:完成不存在任务返回 404', async () => { + const proof = await uploadEngineerProof(); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) - .send({ taskId: 99999999 }); + .send(buildCompletionPayload(99999999, proof)); expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院'); }); + it('失败:未上传完成凭证返回 400', async () => { + const [device] = await createAdjustableDevices(); + const task = await publishPendingTask(device.id, '1.5'); + await acceptPendingTask(task.id); + + const response = await request(ctx.app.getHttpServer()) + .post('/b/tasks/complete') + .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) + .send({ + taskId: task.id, + completionMaterials: [], + }); + + expectErrorEnvelope(response, 400, 'completionMaterials 至少上传 1 项'); + }); + it('状态机失败:重复完成返回 409', async () => { - const task = await publishAssignedTask( - ctx.fixtures.devices.deviceA2Id, - '1', - ); + const [device] = await createAdjustableDevices(); + const task = await publishPendingTask(device.id, '1'); + await acceptPendingTask(task.id); + const proof = await uploadEngineerProof(); const firstComplete = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) - .send({ taskId: task.id }); + .send(buildCompletionPayload(task.id, proof)); expectSuccessEnvelope(firstComplete, 201); const secondComplete = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) - .send({ taskId: task.id }); + .send(buildCompletionPayload(task.id, proof)); - expectErrorEnvelope(secondComplete, 409, '仅已指派任务可执行完成'); + expectErrorEnvelope(secondComplete, 409, '仅已接收任务可执行完成'); }); it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => { + const proof = await uploadEngineerProof(); await assertRoleMatrix({ name: 'POST /b/tasks/complete role matrix', tokens: ctx.tokens, @@ -375,21 +614,19 @@ describe('BTasksController (e2e)', () => { request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${token}`) - .send({ taskId: 99999999 }), + .send(buildCompletionPayload(99999999, proof)), sendWithoutToken: async () => request(ctx.app.getHttpServer()) .post('/b/tasks/complete') - .send({ taskId: 99999999 }), + .send(buildCompletionPayload(99999999, proof)), }); }); }); describe('POST /b/tasks/cancel', () => { - it('成功:DOCTOR 可取消自己创建的已指派任务', async () => { - const task = await publishAssignedTask( - ctx.fixtures.devices.deviceA3Id, - '1.5', - ); + it('成功:DOCTOR 可取消自己创建的待接收任务', async () => { + const [device] = await createAdjustableDevices(); + const task = await publishPendingTask(device.id, '1.5'); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/cancel') @@ -400,6 +637,29 @@ describe('BTasksController (e2e)', () => { expect(response.body.data.status).toBe(TaskStatus.CANCELLED); }); + it('成功:ENGINEER 可取消接收并退回待接收状态', async () => { + const [device] = await createAdjustableDevices(); + const task = await publishPendingTask(device.id, '1.5'); + await acceptPendingTask(task.id); + + const response = await request(ctx.app.getHttpServer()) + .post('/b/tasks/cancel') + .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) + .send({ taskId: task.id, reason: '患者临时取消' }); + + expectSuccessEnvelope(response, 201); + expect(response.body.data.status).toBe(TaskStatus.PENDING); + expect(response.body.data.engineerId).toBeNull(); + + const secondAcceptResponse = await request(ctx.app.getHttpServer()) + .post('/b/tasks/accept') + .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) + .send({ taskId: task.id }); + + expectSuccessEnvelope(secondAcceptResponse, 201); + expect(secondAcceptResponse.body.data.status).toBe(TaskStatus.ACCEPTED); + }); + it('失败:取消不存在任务返回 404', async () => { const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/cancel') @@ -410,15 +670,15 @@ describe('BTasksController (e2e)', () => { }); it('状态机失败:已完成任务不可取消返回 409', async () => { - const task = await publishAssignedTask( - ctx.fixtures.devices.deviceA2Id, - '1.5', - ); + const [device] = await createAdjustableDevices(); + const task = await publishPendingTask(device.id, '1.5'); + await acceptPendingTask(task.id); + const proof = await uploadEngineerProof(); const completeResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) - .send({ taskId: task.id }); + .send(buildCompletionPayload(task.id, proof)); expectSuccessEnvelope(completeResponse, 201); const cancelResponse = await request(ctx.app.getHttpServer()) @@ -426,7 +686,7 @@ describe('BTasksController (e2e)', () => { .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ taskId: task.id }); - expectErrorEnvelope(cancelResponse, 409, '仅待指派/已指派任务可取消'); + expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消'); }); it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { @@ -439,7 +699,7 @@ describe('BTasksController (e2e)', () => { [Role.DIRECTOR]: 404, [Role.LEADER]: 404, [Role.DOCTOR]: 404, - [Role.ENGINEER]: 403, + [Role.ENGINEER]: 404, }, sendAsRole: async (_role, token) => request(ctx.app.getHttpServer()) diff --git a/test/e2e/specs/uploads.e2e-spec.ts b/test/e2e/specs/uploads.e2e-spec.ts index 1b7facc..3a172df 100644 --- a/test/e2e/specs/uploads.e2e-spec.ts +++ b/test/e2e/specs/uploads.e2e-spec.ts @@ -97,7 +97,11 @@ describe('BUploadsController (e2e)', () => { contentType: 'image/png', }); - expectErrorEnvelope(response, 400, '系统管理员上传文件时必须显式指定 hospitalId'); + expectErrorEnvelope( + response, + 400, + '系统管理员上传文件时必须显式指定 hospitalId', + ); }); it('失败:DIRECTOR 查询影像库返回 403', async () => { @@ -116,7 +120,7 @@ describe('BUploadsController (e2e)', () => { expectErrorEnvelope(response, 403, '无权限执行当前操作'); }); - it('角色矩阵:上传允许系统/医院/主任/组长/医生,工程师 403,未登录 401', async () => { + it('角色矩阵:上传允许系统/医院/主任/组长/医生/工程师,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/uploads role matrix', tokens: ctx.tokens, @@ -126,7 +130,7 @@ describe('BUploadsController (e2e)', () => { [Role.DIRECTOR]: 201, [Role.LEADER]: 201, [Role.DOCTOR]: 201, - [Role.ENGINEER]: 403, + [Role.ENGINEER]: 201, }, sendAsRole: async (role, token) => { const req = request(ctx.app.getHttpServer()) @@ -173,7 +177,8 @@ describe('BUploadsController (e2e)', () => { } return req; }, - sendWithoutToken: async () => request(ctx.app.getHttpServer()).get('/b/uploads'), + sendWithoutToken: async () => + request(ctx.app.getHttpServer()).get('/b/uploads'), }); }); }); diff --git a/tyt-admin/src/api/tasks.js b/tyt-admin/src/api/tasks.js index fbaf55d..ceae5aa 100644 --- a/tyt-admin/src/api/tasks.js +++ b/tyt-admin/src/api/tasks.js @@ -12,6 +12,10 @@ export const publishTask = (data) => { return request.post('/b/tasks/publish', data); }; +export const acceptTask = (data) => { + return request.post('/b/tasks/accept', data); +}; + export const completeTask = (data) => { return request.post('/b/tasks/complete', data); }; diff --git a/tyt-admin/src/views/devices/Devices.vue b/tyt-admin/src/views/devices/Devices.vue index 7a5529c..6ae2ab2 100644 --- a/tyt-admin/src/views/devices/Devices.vue +++ b/tyt-admin/src/views/devices/Devices.vue @@ -53,9 +53,19 @@ + + + @@ -712,7 +760,7 @@ import { } from '../../api/patients'; import { getImplantCatalogs } from '../../api/devices'; import { getDictionaries } from '../../api/dictionaries'; -import { getTaskEngineers, publishTask } from '../../api/tasks'; +import { publishTask } from '../../api/tasks'; import { getDepartments, getGroups, @@ -724,10 +772,6 @@ import { } from '../../constants/medical-dictionaries'; import { useUserStore } from '../../store/user'; import SurgeryFormSection from './components/SurgeryFormSection.vue'; -import { - LIFECYCLE_EVENT_LABELS, - LIFECYCLE_EVENT_TAG_TYPES, -} from './patient-form-options'; const userStore = useUserStore(); const route = useRoute(); @@ -768,7 +812,6 @@ const hospitals = ref([]); const departments = ref([]); const groups = ref([]); const doctorOptions = ref([]); -const engineerOptions = ref([]); const implantCatalogOptions = ref([]); const medicalDictionaryOptions = ref(createEmptyMedicalDictionaryOptions()); @@ -794,6 +837,7 @@ const detailDialogVisible = ref(false); const detailTab = ref('profile'); const detailPatient = ref(null); const detailLifecycle = ref([]); +const detailAdjustDeviceId = ref(null); const patientForm = reactive({ name: '', @@ -808,7 +852,6 @@ const initialSurgeryForm = ref(createSurgeryForm()); const appendSurgeryForm = ref(createSurgeryForm()); const adjustForm = reactive({ deviceId: null, - engineerId: null, targetPressure: null, }); @@ -830,6 +873,44 @@ const currentAdjustDevice = computed(() => { ); }); +const TASK_STATUS_LABELS = { + PENDING: '待接收', + ACCEPTED: '已接收', + COMPLETED: '已完成', + CANCELLED: '已取消', +}; + +const TASK_STATUS_TAG_TYPES = { + PENDING: 'warning', + ACCEPTED: 'primary', + COMPLETED: 'success', + CANCELLED: 'info', +}; + +const detailAdjustDeviceOptions = computed(() => { + return buildDetailAdjustDeviceOptions( + detailPatient.value, + detailLifecycle.value, + ); +}); + +const detailAdjustRecords = computed(() => { + const records = Array.isArray(detailLifecycle.value) + ? detailLifecycle.value + : []; + const adjustmentRecords = records.filter( + (item) => item.eventType === 'TASK_PRESSURE_ADJUSTMENT', + ); + + if (!detailAdjustDeviceId.value) { + return adjustmentRecords; + } + + return adjustmentRecords.filter( + (item) => item.device?.id === detailAdjustDeviceId.value, + ); +}); + const patientRules = { name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }], phone: [ @@ -981,13 +1062,17 @@ function validateSurgeryForm(form) { if (!normalizeStringArray(device.proximalPunctureAreas, 2).length) { return `设备 ${index + 1} 请填写近端穿刺区域`; } - if (!normalizeStringArray(device.valvePlacementSites, 2).length) { + if ( + catalog.isValve !== false && + !normalizeStringArray(device.valvePlacementSites, 2).length + ) { return `设备 ${index + 1} 请填写阀门植入部位`; } if (!String(device.distalShuntDirection || '').trim()) { return `设备 ${index + 1} 请选择远端分流方向`; } if ( + catalog.isValve !== false && catalog.isPressureAdjustable && resolveCatalogPressureLevels(device.implantCatalogId).length > 0 ) { @@ -1041,13 +1126,15 @@ function buildSurgeryPayload(form) { device.proximalPunctureAreas, 2, ), - valvePlacementSites: normalizeStringArray( - device.valvePlacementSites, - 2, - ), + valvePlacementSites: normalizeStringArray(device.valvePlacementSites, 2) + .length + ? normalizeStringArray(device.valvePlacementSites, 2) + : undefined, distalShuntDirection: String(device.distalShuntDirection || '').trim(), initialPressure: - normalizePressureLabel(device.initialPressure) || undefined, + resolveCatalog(device.implantCatalogId)?.isValve === false + ? undefined + : normalizePressureLabel(device.initialPressure) || undefined, implantNotes: normalizeOptionalString(device.implantNotes), labelImageUrl: normalizeOptionalString(device.labelImageUrl), }; @@ -1167,12 +1254,6 @@ function formatAdjustDeviceLabel(device) { ].join(' | '); } -function formatEngineerLabel(engineer) { - return [engineer.name || '-', engineer.phone || '-'] - .filter(Boolean) - .join(' | '); -} - function formatDateTime(value) { if (!value) { return '-'; @@ -1194,35 +1275,17 @@ function formatValue(value) { return value == null || value === '' ? '-' : value; } -function getLifecycleEventLabel(type) { - return LIFECYCLE_EVENT_LABELS[type] || type; +function getTaskStatusLabel(status) { + return TASK_STATUS_LABELS[status] || status || '-'; } -function getLifecycleEventTagType(type) { - return LIFECYCLE_EVENT_TAG_TYPES[type] || 'info'; +function getTaskStatusTagType(status) { + return TASK_STATUS_TAG_TYPES[status] || 'info'; } -function formatLifecycleSummary(event) { - if (event.eventType === 'SURGERY') { - return `${event.surgery?.surgeryName || '-'} | 主刀 ${ - event.surgery?.surgeonName || '-' - } | 植入设备 ${event.devices?.length || 0} 台`; - } - - return `${event.device?.implantName || event.device?.implantModel || '设备'} | 压力 ${ - event.taskItem?.oldPressure ?? '-' - } -> ${event.taskItem?.targetPressure ?? '-'}`; -} - -function formatLifecycleMeta(event) { - if (event.eventType === 'SURGERY') { - return `原发病 ${event.surgery?.primaryDisease || '-'} | 脑积水类型 ${formatList( - event.surgery?.hydrocephalusTypes, - )}`; - } - - return `任务状态 ${event.task?.status || '-'} | 手术 ${ - event.surgery?.surgeryName || '-' +function formatAdjustRecordPressureChange(event) { + return `${event.taskItem?.oldPressure ?? '-'} -> ${ + event.taskItem?.targetPressure ?? '-' }`; } @@ -1312,9 +1375,7 @@ async function fetchOrgNodesForDoctorTree( async function fetchImplantCatalogOptions() { const res = await getImplantCatalogs(); - implantCatalogOptions.value = (Array.isArray(res) ? res : []).filter( - (item) => item.isPressureAdjustable !== false, - ); + implantCatalogOptions.value = Array.isArray(res) ? res : []; } async function fetchMedicalDictionaryOptions() { @@ -1322,16 +1383,6 @@ async function fetchMedicalDictionaryOptions() { medicalDictionaryOptions.value = groupMedicalDictionaryItems(res); } -async function loadAssignableEngineers(hospitalId) { - const params = {}; - if (hospitalId) { - params.hospitalId = hospitalId; - } - - const res = await getTaskEngineers(params); - engineerOptions.value = Array.isArray(res) ? res : []; -} - async function fetchData() { if (isSystemAdmin.value && !searchForm.hospitalId) { allPatients.value = []; @@ -1599,11 +1650,45 @@ function canAdjustDevice(device) { return canPublishAdjustTask.value && isAdjustableDeviceAvailable(device); } +function buildDetailAdjustDeviceOptions(patient, lifecycle) { + const adjustableRecords = Array.isArray(lifecycle) + ? lifecycle.filter((item) => item.eventType === 'TASK_PRESSURE_ADJUSTMENT') + : []; + const recordDeviceIds = new Set( + adjustableRecords.map((item) => item.device?.id).filter(Boolean), + ); + const patientDevices = Array.isArray(patient?.devices) ? patient.devices : []; + const options = patientDevices + .filter( + (device) => + device?.isPressureAdjustable !== false && + (device?.status === 'ACTIVE' || recordDeviceIds.has(device.id)), + ) + .map((device) => ({ + id: device.id, + label: formatAdjustDeviceLabel(device), + })); + + if (options.length > 0) { + return options; + } + + return adjustableRecords + .map((item) => item.device) + .filter(Boolean) + .filter( + (device, index, list) => + list.findIndex((item) => item?.id === device?.id) === index, + ) + .map((device) => ({ + id: device.id, + label: formatAdjustDeviceLabel(device), + })); +} + function resetAdjustDialog() { currentAdjustPatient.value = null; - engineerOptions.value = []; adjustForm.deviceId = null; - adjustForm.engineerId = null; adjustForm.targetPressure = null; } @@ -1632,15 +1717,8 @@ async function openPatientAdjustDialog(row) { return; } - await loadAssignableEngineers(detail.hospital?.id || detail.hospitalId); - if (!engineerOptions.value.length) { - ElMessage.warning('当前医院下暂无可指派的工程师'); - return; - } - currentAdjustPatient.value = detail; adjustForm.deviceId = adjustableDevices[0].id; - adjustForm.engineerId = engineerOptions.value[0].id; handleAdjustDeviceChange(adjustForm.deviceId); adjustDialogVisible.value = true; } @@ -1651,34 +1729,26 @@ async function openAdjustDialog(device) { return; } - await loadAssignableEngineers(patient.hospital?.id || patient.hospitalId); - if (!engineerOptions.value.length) { - ElMessage.warning('当前医院下暂无可指派的工程师'); - return; - } - currentAdjustPatient.value = patient; adjustForm.deviceId = device.id; - adjustForm.engineerId = engineerOptions.value[0].id; handleAdjustDeviceChange(device.id); adjustDialogVisible.value = true; } async function handleSubmitAdjustTask() { - if (!currentAdjustDevice.value || !adjustForm.engineerId) { - ElMessage.warning('请选择接收人和目标挡位'); + if (!currentAdjustDevice.value) { + ElMessage.warning('请选择调压设备和目标挡位'); return; } if (adjustForm.targetPressure == null) { - ElMessage.warning('请选择接收人和目标挡位'); + ElMessage.warning('请选择调压设备和目标挡位'); return; } adjustSubmitLoading.value = true; try { await publishTask({ - engineerId: adjustForm.engineerId, items: [ { deviceId: adjustForm.deviceId, @@ -1687,7 +1757,7 @@ async function handleSubmitAdjustTask() { ], }); - ElMessage.success('调压任务已创建,完成后当前压力会自动刷新'); + ElMessage.success('调压任务已发布,待本院工程师接收'); adjustDialogVisible.value = false; if (detailPatient.value?.id === currentAdjustPatient.value?.id) { @@ -1696,7 +1766,7 @@ async function handleSubmitAdjustTask() { phone: detailPatient.value.phone, idCard: detailPatient.value.idCard, }); - detailTab.value = 'lifecycle'; + detailTab.value = 'adjustments'; } } finally { adjustSubmitLoading.value = false; @@ -1723,6 +1793,7 @@ async function openDetailDialog(row) { detailTab.value = 'profile'; detailPatient.value = null; detailLifecycle.value = []; + detailAdjustDeviceId.value = null; try { const detailPromise = getPatientById(row.id); @@ -1746,6 +1817,11 @@ async function openDetailDialog(row) { detailLifecycle.value = fullLifecycle.filter( (item) => item.patient?.id === detail.id, ); + const deviceOptions = buildDetailAdjustDeviceOptions( + detail, + detailLifecycle.value, + ); + detailAdjustDeviceId.value = deviceOptions[0]?.id || null; } finally { detailLoading.value = false; } @@ -1988,6 +2064,32 @@ onMounted(async () => { object-fit: contain; } +.adjust-records-panel { + display: grid; + gap: 14px; +} + +.adjust-records-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid #d7e6f5; + border-radius: 16px; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); +} + +.adjust-records-toolbar-title { + color: #64748b; + font-size: 13px; +} + +.adjust-records-filter { + width: 360px; + max-width: 100%; +} + .adjust-dialog-body { display: grid; gap: 16px; @@ -2000,7 +2102,8 @@ onMounted(async () => { @media (max-width: 768px) { .section-card-head, .surgery-card-head, - .device-detail-head { + .device-detail-head, + .adjust-records-toolbar { flex-direction: column; align-items: flex-start; } @@ -2008,5 +2111,9 @@ onMounted(async () => { .surgery-card-tags { justify-content: flex-start; } + + .adjust-records-filter { + width: 100%; + } } diff --git a/tyt-admin/src/views/patients/components/SurgeryFormSection.vue b/tyt-admin/src/views/patients/components/SurgeryFormSection.vue index 1e884e3..fbc437a 100644 --- a/tyt-admin/src/views/patients/components/SurgeryFormSection.vue +++ b/tyt-admin/src/views/patients/components/SurgeryFormSection.vue @@ -273,13 +273,24 @@ /> - 调压设备 + {{ + resolveCatalog(device.implantCatalogId)?.isValve === false + ? '管子' + : '阀门' + }}
挡位: @@ -342,7 +353,11 @@ - + - + -
当前压力创建后默认继承初始压力,后续以调压任务完成结果为准
@@ -554,6 +564,12 @@ const formatCatalogLabel = (catalog) => { const handleCatalogChange = (device) => { const catalog = resolveCatalog(device.implantCatalogId); + if (catalog?.isValve === false) { + device.valvePlacementSites = []; + device.initialPressure = ''; + return; + } + if (!catalog?.isPressureAdjustable) { device.initialPressure = ''; return; diff --git a/tyt-admin/src/views/tasks/Tasks.vue b/tyt-admin/src/views/tasks/Tasks.vue index 4104ca8..eab722c 100644 --- a/tyt-admin/src/views/tasks/Tasks.vue +++ b/tyt-admin/src/views/tasks/Tasks.vue @@ -54,7 +54,7 @@ @@ -140,6 +140,92 @@ {{ formatDateTime(row.createdAt) }} + + + + + +
@@ -155,29 +241,148 @@ />
+ + + + +
+
+ 患者 + {{ completeDialogRow.patient?.name || '-' }} +
+
+ 设备 + + {{ + completeDialogRow.device?.implantName || + completeDialogRow.device?.implantModel || + '-' + }} + +
+
+ 目标挡位 + {{ formatPressureChange(completeDialogRow) }} +
+
+ +
+ +
+ +
+ 请先上传完成凭证 +
+ +
+
+
+ {{ material.name || material.type || '完成凭证' }} +
+ +
+
+ + +