调压任务流程从“发布即指派”改为“发布待接收(PENDING) -> 工程师接收(ACCEPTED) -> 完成(COMPLETED)”。
新增工程师“取消接收”能力,任务可从 ACCEPTED 回退到 PENDING。 发布任务不再要求 engineerId,并增加同设备存在未结束任务时的重复发布拦截。 完成任务新增 completionMaterials 必填校验,仅允许图片/视频凭证,并在完成时落库。 植入物目录新增 isValve,区分阀门与管子;非阀门不维护压力挡位,阀门至少 1 个挡位。 患者设备与任务查询返回新增字段,前端任务页支持接收/取消接收/上传凭证后完成。 增补 Prisma 迁移、接口文档、E2E 用例与夹具修复逻辑。
This commit is contained in:
parent
2bfe8ac8c8
commit
0b5640a977
4
.gitignore
vendored
4
.gitignore
vendored
@ -59,3 +59,7 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
|
|
||||||
/tyt-admin/dist
|
/tyt-admin/dist
|
||||||
/tyt-admin/node_modules
|
/tyt-admin/node_modules
|
||||||
|
|
||||||
|
# Runtime upload assets
|
||||||
|
/storage/uploads
|
||||||
|
/storage/tmp-uploads
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- 提供“全局植入物目录”管理,供患者手术表单选择。
|
- 提供“全局植入物目录”管理,供患者手术表单选择。
|
||||||
- 维护患者手术下的植入实例记录。
|
- 维护患者手术下的植入实例记录。
|
||||||
- 支持为可调压器械配置挡位列表。
|
- 支持区分“阀门 / 管子”,并仅为阀门配置挡位列表。
|
||||||
- 支持管理员按医院、患者、状态和关键词分页查询患者植入实例。
|
- 支持管理员按医院、患者、状态和关键词分页查询患者植入实例。
|
||||||
|
|
||||||
## 2. 设备实例
|
## 2. 设备实例
|
||||||
@ -17,6 +17,7 @@
|
|||||||
- `surgeryId`:归属手术,可为空
|
- `surgeryId`:归属手术,可为空
|
||||||
- `implantCatalogId`:型号字典 ID,可为空
|
- `implantCatalogId`:型号字典 ID,可为空
|
||||||
- `implantModel` / `implantManufacturer` / `implantName`:历史快照
|
- `implantModel` / `implantManufacturer` / `implantName`:历史快照
|
||||||
|
- `isValve`:是否为阀门
|
||||||
- `isPressureAdjustable`:是否可调压
|
- `isPressureAdjustable`:是否可调压
|
||||||
- `isAbandoned`:是否弃用
|
- `isAbandoned`:是否弃用
|
||||||
- `currentPressure`:当前压力挡位标签
|
- `currentPressure`:当前压力挡位标签
|
||||||
@ -35,8 +36,9 @@
|
|||||||
- `modelCode`:型号编码,唯一
|
- `modelCode`:型号编码,唯一
|
||||||
- `manufacturer`:厂商
|
- `manufacturer`:厂商
|
||||||
- `name`:名称
|
- `name`:名称
|
||||||
|
- `isValve`:是否为阀门;关闭时表示管子或附件
|
||||||
- `pressureLevels`:可调压器械的挡位字符串标签列表
|
- `pressureLevels`:可调压器械的挡位字符串标签列表
|
||||||
- `isPressureAdjustable`:是否可调压
|
- `isPressureAdjustable`:后端按 `isValve` 自动派生
|
||||||
- `notes`:目录备注
|
- `notes`:目录备注
|
||||||
|
|
||||||
可见性:
|
可见性:
|
||||||
@ -47,6 +49,8 @@
|
|||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
|
- 非阀门目录项不会保存压力挡位,前端也不会显示压力录入区域。
|
||||||
|
- 阀门目录项至少需要配置一个挡位。
|
||||||
- 挡位列表按字符串标签保存,例如 `["0.5", "1", "1.5"]` 或 `["10", "20", "30"]`。
|
- 挡位列表按字符串标签保存,例如 `["0.5", "1", "1.5"]` 或 `["10", "20", "30"]`。
|
||||||
- 保存前会自动标准化并去重排序,例如 `["01.0", "1.50", "1"]` 最终会整理为 `["1", "1.5"]`。
|
- 保存前会自动标准化并去重排序,例如 `["01.0", "1.50", "1"]` 最终会整理为 `["1", "1.5"]`。
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
- 登录页:`/auth/login`,支持可选 `hospitalId`。
|
- 登录页:`/auth/login`,支持可选 `hospitalId`。
|
||||||
- 首页看板:按角色拉取组织与患者统计。
|
- 首页看板:按角色拉取组织与患者统计。
|
||||||
- 设备页:新增管理员专用设备 CRUD,复用真实设备接口。
|
- 设备页:新增管理员专用设备 CRUD,复用真实设备接口。
|
||||||
- 任务页:改为只读调压记录页,接入真实任务列表接口。
|
- 任务页:接入真实任务列表、工程师接收与完成接口。
|
||||||
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
|
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
|
||||||
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`),
|
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`),
|
||||||
后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。
|
后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。
|
||||||
@ -20,7 +20,7 @@
|
|||||||
- `GET /b/tasks` 返回 `{ list, total, page, pageSize }`,供任务页只读展示调压记录。
|
- `GET /b/tasks` 返回 `{ list, total, page, pageSize }`,供任务页只读展示调压记录。
|
||||||
- `POST /b/uploads` 使用 `multipart/form-data` 上传文件,返回上传资产元数据。
|
- `POST /b/uploads` 使用 `multipart/form-data` 上传文件,返回上传资产元数据。
|
||||||
- `GET /b/uploads` 返回 `{ list, total, page, pageSize }`,仅供系统管理员/医院管理员影像库分页展示。
|
- `GET /b/uploads` 返回 `{ list, total, page, pageSize }`,仅供系统管理员/医院管理员影像库分页展示。
|
||||||
- `GET /b/tasks/engineers` 返回可选接收工程师列表,患者页发布调压任务前需要先拉取。
|
- `GET /b/tasks/engineers` 返回当前角色可见的医院工程师列表。
|
||||||
- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCard`。
|
- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCard`。
|
||||||
- 患者表单中的 `idCard` 字段直接传身份证号;
|
- 患者表单中的 `idCard` 字段直接传身份证号;
|
||||||
服务端只会做去空格与 `x/X` 标准化,不会转哈希。
|
服务端只会做去空格与 `x/X` 标准化,不会转哈希。
|
||||||
@ -29,8 +29,8 @@
|
|||||||
## 3. 角色权限提示
|
## 3. 角色权限提示
|
||||||
|
|
||||||
- 任务接口权限:
|
- 任务接口权限:
|
||||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时必须指定接收工程师;可取消自己创建的任务
|
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时不再指定接收工程师;可取消自己创建的任务
|
||||||
- `ENGINEER`:仅可完成分配给自己的任务
|
- `ENGINEER`:可接收本院待接收任务;仅可完成自己已接收的任务
|
||||||
- 患者列表权限:
|
- 患者列表权限:
|
||||||
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
||||||
- 用户管理接口:
|
- 用户管理接口:
|
||||||
@ -61,7 +61,7 @@
|
|||||||
- `patients`
|
- `patients`
|
||||||
- `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问
|
- `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问
|
||||||
|
|
||||||
患者页负责发起调压任务并指定接收人,任务页仅查看调压记录,不再提供发布或接收入口。
|
患者页负责发起调压任务,任务页负责查看、接收与完成调压任务。
|
||||||
|
|
||||||
患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。
|
患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。
|
||||||
|
|
||||||
|
|||||||
@ -58,6 +58,7 @@
|
|||||||
|
|
||||||
- `implantCatalogId`:植入物型号字典 ID
|
- `implantCatalogId`:植入物型号字典 ID
|
||||||
- `implantModel` / `implantManufacturer` / `implantName`:型号快照
|
- `implantModel` / `implantManufacturer` / `implantName`:型号快照
|
||||||
|
- `isValve`:是否为阀门
|
||||||
- `isPressureAdjustable`:是否可调压
|
- `isPressureAdjustable`:是否可调压
|
||||||
- `isAbandoned`:是否已弃用
|
- `isAbandoned`:是否已弃用
|
||||||
- `shuntMode`:分流方式
|
- `shuntMode`:分流方式
|
||||||
@ -75,6 +76,7 @@
|
|||||||
- 旧设备弃用后,`TaskItem` 历史不会删除。
|
- 旧设备弃用后,`TaskItem` 历史不会删除。
|
||||||
- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。
|
- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。
|
||||||
- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。
|
- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。
|
||||||
|
- 管子/附件类型不会显示“阀门植入部位”和“初始压力”录入项。
|
||||||
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的字符串标签值,例如 `0.5 / 1 / 1.5 / 10 / 20 / 30`。
|
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的字符串标签值,例如 `0.5 / 1 / 1.5 / 10 / 20 / 30`。
|
||||||
- 挡位标签保存前会自动标准化,例如 `01.0 -> 1`、`1.50 -> 1.5`。
|
- 挡位标签保存前会自动标准化,例如 `01.0 -> 1`、`1.50 -> 1.5`。
|
||||||
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`;发布调压任务时不会立刻改当前压力,只有工程师完成任务后才会更新。
|
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`;发布调压任务时不会立刻改当前压力,只有工程师完成任务后才会更新。
|
||||||
|
|||||||
@ -7,24 +7,26 @@
|
|||||||
|
|
||||||
## 2. 状态机
|
## 2. 状态机
|
||||||
|
|
||||||
- 当前发布流程:`ACCEPTED -> COMPLETED`
|
- 当前发布流程:`PENDING -> ACCEPTED -> COMPLETED`
|
||||||
- 当前取消流程:`ACCEPTED -> CANCELLED`
|
- 当前工程师撤回流程:`ACCEPTED -> PENDING`
|
||||||
- 历史兼容:保留 `PENDING` 状态枚举,便于兼容旧数据与旧任务记录
|
- 当前取消流程:`PENDING/ACCEPTED -> CANCELLED`
|
||||||
|
- `PENDING` 表示任务已发布,等待本院工程师接收
|
||||||
|
|
||||||
非法流转会返回 `409` 冲突错误(中文消息)。
|
非法流转会返回 `409` 冲突错误(中文消息)。
|
||||||
|
|
||||||
## 3. 角色权限
|
## 3. 角色权限
|
||||||
|
|
||||||
- 系统管理员/医院管理员/医生/主任/组长:发布任务时必须直接指定接收工程师,并且只能取消自己创建的任务
|
- 系统管理员/医院管理员/医生/主任/组长:发布任务时不再指定工程师,只能取消自己创建的任务
|
||||||
- 工程师:不能再执行“接收”动作,只能完成指派给自己的任务
|
- 工程师:可接收本院 `PENDING` 任务;接收后只能由接收工程师自己完成,或取消接收并退回 `PENDING`
|
||||||
- 其他角色:默认拒绝
|
- 其他角色:默认拒绝
|
||||||
|
|
||||||
补充:
|
补充:
|
||||||
|
|
||||||
- `GET /b/tasks/engineers`:返回当前角色可选的接收工程师列表,系统管理员可按医院筛选。
|
- `GET /b/tasks/engineers`:返回当前角色可见的医院工程师列表,系统管理员可按医院筛选。
|
||||||
- `GET /b/tasks`:返回当前角色可见的调压记录列表,系统管理员可按医院筛选。
|
- `GET /b/tasks`:返回当前角色可见的调压记录列表,系统管理员可按医院筛选。
|
||||||
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
|
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
|
||||||
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
|
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
|
||||||
|
- 如果当前设备已经存在 `PENDING / ACCEPTED` 调压任务,则禁止再次发布;同一患者的其他设备不受影响。
|
||||||
|
|
||||||
## 4. 记录列表
|
## 4. 记录列表
|
||||||
|
|
||||||
@ -35,6 +37,7 @@
|
|||||||
- 手术名称
|
- 手术名称
|
||||||
- 设备信息
|
- 设备信息
|
||||||
- 旧压力 / 目标压力 / 当前压力(均为字符串挡位标签)
|
- 旧压力 / 目标压力 / 当前压力(均为字符串挡位标签)
|
||||||
|
- 完成凭证(图片/视频)
|
||||||
- 创建人 / 接收人 / 发布时间
|
- 创建人 / 接收人 / 发布时间
|
||||||
|
|
||||||
## 5. 事件触发
|
## 5. 事件触发
|
||||||
@ -42,6 +45,7 @@
|
|||||||
状态变化后会发出事件:
|
状态变化后会发出事件:
|
||||||
|
|
||||||
- `task.published`
|
- `task.published`
|
||||||
|
- `task.accepted`
|
||||||
- `task.completed`
|
- `task.completed`
|
||||||
- `task.cancelled`
|
- `task.cancelled`
|
||||||
|
|
||||||
@ -52,8 +56,9 @@
|
|||||||
`completeTask` 在单事务中执行:
|
`completeTask` 在单事务中执行:
|
||||||
|
|
||||||
1. 更新任务状态为 `COMPLETED`
|
1. 更新任务状态为 `COMPLETED`
|
||||||
2. 读取 `TaskItem.targetPressure`
|
2. 校验至少上传 1 条图片或视频凭证
|
||||||
3. 批量更新关联 `Device.currentPressure`
|
3. 读取 `TaskItem.targetPressure`
|
||||||
|
4. 批量更新关联 `Device.currentPressure`
|
||||||
|
|
||||||
确保任务状态与设备压力一致性。
|
确保任务状态与设备压力一致性。
|
||||||
|
|
||||||
@ -61,3 +66,4 @@
|
|||||||
|
|
||||||
- `publishTask` 只负责生成任务和目标挡位,不会立刻修改设备当前压力。
|
- `publishTask` 只负责生成任务和目标挡位,不会立刻修改设备当前压力。
|
||||||
- 只有工程师完成任务后,目标挡位才会回写到设备实例。
|
- 只有工程师完成任务后,目标挡位才会回写到设备实例。
|
||||||
|
- 完成任务时必须上传至少一张图片或一个视频,凭证会保存到 `Task.completionMaterials`。
|
||||||
|
|||||||
@ -33,7 +33,7 @@
|
|||||||
## 3. 接口
|
## 3. 接口
|
||||||
|
|
||||||
- `POST /b/uploads`
|
- `POST /b/uploads`
|
||||||
- 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN / DIRECTOR / LEADER / DOCTOR`
|
- 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN / DIRECTOR / LEADER / DOCTOR / ENGINEER`
|
||||||
- 表单字段:
|
- 表单字段:
|
||||||
- `file`:二进制文件
|
- `file`:二进制文件
|
||||||
- `hospitalId`:仅 `SYSTEM_ADMIN` 上传时必填
|
- `hospitalId`:仅 `SYSTEM_ADMIN` 上传时必填
|
||||||
@ -50,6 +50,7 @@
|
|||||||
|
|
||||||
- 患者手术表单中的“术前 CT 影像/资料”支持直接上传,上传成功后自动回填 `type/name/url`。
|
- 患者手术表单中的“术前 CT 影像/资料”支持直接上传,上传成功后自动回填 `type/name/url`。
|
||||||
- 设备表单中的“植入物标签”支持直接上传图片,上传成功后自动回填 `labelImageUrl`。
|
- 设备表单中的“植入物标签”支持直接上传图片,上传成功后自动回填 `labelImageUrl`。
|
||||||
|
- 工程师完成调压任务时,可直接上传图片或视频作为完成凭证。
|
||||||
- 患者详情页会直接预览术前图片、视频和设备标签。
|
- 患者详情页会直接预览术前图片、视频和设备标签。
|
||||||
- 单独新增“影像库”页面,按图片/视频/文件分页查看所有上传资产。
|
- 单独新增“影像库”页面,按图片/视频/文件分页查看所有上传资产。
|
||||||
- 页面访问权限仅 `SYSTEM_ADMIN / HOSPITAL_ADMIN`
|
- 页面访问权限仅 `SYSTEM_ADMIN / HOSPITAL_ADMIN`
|
||||||
|
|||||||
@ -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;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "Task"
|
||||||
|
ADD COLUMN IF NOT EXISTS "completionMaterials" JSONB;
|
||||||
@ -180,6 +180,8 @@ model ImplantCatalog {
|
|||||||
modelCode String @unique
|
modelCode String @unique
|
||||||
manufacturer String
|
manufacturer String
|
||||||
name String
|
name String
|
||||||
|
// 是否为阀门;关闭时表示管子/附件,不提供压力挡位。
|
||||||
|
isValve Boolean @default(true)
|
||||||
// 可调压器械的可选挡位,由系统管理员维护。
|
// 可调压器械的可选挡位,由系统管理员维护。
|
||||||
pressureLevels String[] @default([])
|
pressureLevels String[] @default([])
|
||||||
isPressureAdjustable Boolean @default(true)
|
isPressureAdjustable Boolean @default(true)
|
||||||
@ -235,6 +237,7 @@ model Device {
|
|||||||
implantModel String?
|
implantModel String?
|
||||||
implantManufacturer String?
|
implantManufacturer String?
|
||||||
implantName String?
|
implantName String?
|
||||||
|
isValve Boolean @default(true)
|
||||||
isPressureAdjustable Boolean @default(true)
|
isPressureAdjustable Boolean @default(true)
|
||||||
// 二次手术后旧设备可标记弃用,但历史调压任务仍需保留。
|
// 二次手术后旧设备可标记弃用,但历史调压任务仍需保留。
|
||||||
isAbandoned Boolean @default(false)
|
isAbandoned Boolean @default(false)
|
||||||
@ -258,16 +261,18 @@ model Device {
|
|||||||
|
|
||||||
// 主任务表:记录调压任务主单。
|
// 主任务表:记录调压任务主单。
|
||||||
model Task {
|
model Task {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
status TaskStatus @default(PENDING)
|
status TaskStatus @default(PENDING)
|
||||||
creatorId Int
|
creatorId Int
|
||||||
engineerId Int?
|
engineerId Int?
|
||||||
hospitalId Int
|
hospitalId Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
// 工程师完成任务时上传的图片/视频凭证。
|
||||||
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
|
completionMaterials Json?
|
||||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
||||||
items TaskItem[]
|
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
|
||||||
|
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||||
|
items TaskItem[]
|
||||||
|
|
||||||
@@index([hospitalId, status, createdAt])
|
@@index([hospitalId, status, createdAt])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,15 +65,18 @@ export const MESSAGES = {
|
|||||||
ITEMS_REQUIRED: '任务明细 items 不能为空',
|
ITEMS_REQUIRED: '任务明细 items 不能为空',
|
||||||
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
|
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
|
||||||
DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院',
|
DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院',
|
||||||
|
DUPLICATE_DEVICE_OPEN_TASK: '该设备已有待处理调压任务,请勿重复发布',
|
||||||
ENGINEER_REQUIRED: '接收工程师必选',
|
ENGINEER_REQUIRED: '接收工程师必选',
|
||||||
ENGINEER_INVALID: '工程师必须为当前医院有效工程师',
|
ENGINEER_INVALID: '工程师必须为当前医院有效工程师',
|
||||||
TASK_NOT_FOUND: '任务不存在或不属于当前医院',
|
TASK_NOT_FOUND: '任务不存在或不属于当前医院',
|
||||||
ACCEPT_DISABLED: '当前流程不支持工程师接收,请由创建人直接指定接收工程师',
|
ACCEPT_ONLY_PENDING: '仅待接收任务可执行接收',
|
||||||
ACCEPT_ONLY_PENDING: '仅待指派任务可执行接收',
|
COMPLETE_ONLY_ACCEPTED: '仅已接收任务可执行完成',
|
||||||
COMPLETE_ONLY_ACCEPTED: '仅已指派任务可执行完成',
|
COMPLETE_MATERIALS_REQUIRED: '完成任务至少上传一张图片或一个视频',
|
||||||
CANCEL_ONLY_PENDING_ACCEPTED: '仅待指派/已指派任务可取消',
|
COMPLETE_MATERIAL_TYPE_INVALID: '完成任务仅支持图片或视频凭证',
|
||||||
ENGINEER_ALREADY_ASSIGNED: '任务已指派给其他工程师',
|
CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消',
|
||||||
|
ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收',
|
||||||
ENGINEER_ONLY_ASSIGNEE: '仅任务接收人可完成任务',
|
ENGINEER_ONLY_ASSIGNEE: '仅任务接收人可完成任务',
|
||||||
|
CANCEL_ONLY_ASSIGNEE: '仅任务接收人可取消接收',
|
||||||
CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务',
|
CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务',
|
||||||
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
|
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
|
||||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||||
@ -113,6 +116,7 @@ export const MESSAGES = {
|
|||||||
CATALOG_MODEL_DUPLICATE: '植入物型号编码已存在',
|
CATALOG_MODEL_DUPLICATE: '植入物型号编码已存在',
|
||||||
CATALOG_SCOPE_FORBIDDEN: '当前角色无权限维护该植入物型号',
|
CATALOG_SCOPE_FORBIDDEN: '当前角色无权限维护该植入物型号',
|
||||||
CATALOG_DELETE_CONFLICT: '植入物型号已被患者手术引用,无法删除',
|
CATALOG_DELETE_CONFLICT: '植入物型号已被患者手术引用,无法删除',
|
||||||
|
VALVE_PRESSURE_REQUIRED: '阀门类型至少需要配置一个压力挡位',
|
||||||
PRESSURE_LEVEL_INVALID: '压力值不在该植入物配置的挡位范围内',
|
PRESSURE_LEVEL_INVALID: '压力值不在该植入物配置的挡位范围内',
|
||||||
DEVICE_NOT_ADJUSTABLE: '仅可调压设备允许创建调压任务',
|
DEVICE_NOT_ADJUSTABLE: '仅可调压设备允许创建调压任务',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const CATALOG_SELECT = {
|
|||||||
modelCode: true,
|
modelCode: true,
|
||||||
manufacturer: true,
|
manufacturer: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
isValve: true,
|
||||||
pressureLevels: true,
|
pressureLevels: true,
|
||||||
isPressureAdjustable: true,
|
isPressureAdjustable: true,
|
||||||
notes: true,
|
notes: true,
|
||||||
@ -214,7 +215,8 @@ export class DevicesService {
|
|||||||
*/
|
*/
|
||||||
async createCatalog(actor: ActorContext, dto: CreateImplantCatalogDto) {
|
async createCatalog(actor: ActorContext, dto: CreateImplantCatalogDto) {
|
||||||
this.assertSystemAdmin(actor);
|
this.assertSystemAdmin(actor);
|
||||||
const isPressureAdjustable = dto.isPressureAdjustable ?? true;
|
const isValve = dto.isValve ?? true;
|
||||||
|
const isPressureAdjustable = isValve;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.prisma.implantCatalog.create({
|
return await this.prisma.implantCatalog.create({
|
||||||
@ -225,9 +227,10 @@ export class DevicesService {
|
|||||||
'manufacturer',
|
'manufacturer',
|
||||||
),
|
),
|
||||||
name: this.normalizeRequiredString(dto.name, 'name'),
|
name: this.normalizeRequiredString(dto.name, 'name'),
|
||||||
|
isValve,
|
||||||
pressureLevels: this.normalizePressureLevels(
|
pressureLevels: this.normalizePressureLevels(
|
||||||
dto.pressureLevels,
|
dto.pressureLevels,
|
||||||
isPressureAdjustable,
|
isValve,
|
||||||
),
|
),
|
||||||
isPressureAdjustable,
|
isPressureAdjustable,
|
||||||
notes:
|
notes:
|
||||||
@ -258,8 +261,8 @@ export class DevicesService {
|
|||||||
) {
|
) {
|
||||||
this.assertSystemAdmin(actor);
|
this.assertSystemAdmin(actor);
|
||||||
const current = await this.findWritableCatalog(id);
|
const current = await this.findWritableCatalog(id);
|
||||||
const nextIsPressureAdjustable =
|
const nextIsValve = dto.isValve ?? current.isValve;
|
||||||
dto.isPressureAdjustable ?? current.isPressureAdjustable;
|
const nextIsPressureAdjustable = nextIsValve;
|
||||||
|
|
||||||
const data: Prisma.ImplantCatalogUpdateInput = {};
|
const data: Prisma.ImplantCatalogUpdateInput = {};
|
||||||
if (dto.modelCode !== undefined) {
|
if (dto.modelCode !== undefined) {
|
||||||
@ -274,16 +277,14 @@ export class DevicesService {
|
|||||||
if (dto.name !== undefined) {
|
if (dto.name !== undefined) {
|
||||||
data.name = this.normalizeRequiredString(dto.name, 'name');
|
data.name = this.normalizeRequiredString(dto.name, 'name');
|
||||||
}
|
}
|
||||||
if (dto.isPressureAdjustable !== undefined) {
|
if (dto.isValve !== undefined) {
|
||||||
data.isPressureAdjustable = dto.isPressureAdjustable;
|
data.isValve = dto.isValve;
|
||||||
|
data.isPressureAdjustable = nextIsPressureAdjustable;
|
||||||
}
|
}
|
||||||
if (
|
if (dto.pressureLevels !== undefined || dto.isValve !== undefined) {
|
||||||
dto.pressureLevels !== undefined ||
|
|
||||||
dto.isPressureAdjustable !== undefined
|
|
||||||
) {
|
|
||||||
data.pressureLevels = this.normalizePressureLevels(
|
data.pressureLevels = this.normalizePressureLevels(
|
||||||
dto.pressureLevels ?? current.pressureLevels,
|
dto.pressureLevels ?? current.pressureLevels,
|
||||||
nextIsPressureAdjustable,
|
nextIsValve,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (dto.notes !== undefined) {
|
if (dto.notes !== undefined) {
|
||||||
@ -606,13 +607,21 @@ export class DevicesService {
|
|||||||
*/
|
*/
|
||||||
private normalizePressureLevels(
|
private normalizePressureLevels(
|
||||||
pressureLevels: unknown[] | undefined,
|
pressureLevels: unknown[] | undefined,
|
||||||
isPressureAdjustable: boolean,
|
isValve: boolean,
|
||||||
) {
|
) {
|
||||||
if (!isPressureAdjustable) {
|
if (!isValve) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizePressureLabelList(pressureLevels, 'pressureLevels');
|
const normalized = normalizePressureLabelList(
|
||||||
|
pressureLevels,
|
||||||
|
'pressureLevels',
|
||||||
|
);
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
throw new BadRequestException(MESSAGES.DEVICE.VALVE_PRESSURE_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -34,6 +34,15 @@ export class CreateImplantCatalogDto {
|
|||||||
@IsString({ message: 'name 必须是字符串' })
|
@IsString({ message: 'name 必须是字符串' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '是否为阀门,关闭时表示管子或附件',
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@ToBoolean()
|
||||||
|
@IsBoolean({ message: 'isValve 必须是布尔值' })
|
||||||
|
isValve?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '可调压器械的挡位列表,按字符串挡位标签录入',
|
description: '可调压器械的挡位列表,按字符串挡位标签录入',
|
||||||
type: [String],
|
type: [String],
|
||||||
@ -45,15 +54,6 @@ export class CreateImplantCatalogDto {
|
|||||||
@IsString({ each: true, message: 'pressureLevels 必须为字符串数组' })
|
@IsString({ each: true, message: 'pressureLevels 必须为字符串数组' })
|
||||||
pressureLevels?: string[];
|
pressureLevels?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '是否支持调压,默认 true',
|
|
||||||
example: true,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@ToBoolean()
|
|
||||||
@IsBoolean({ message: 'isPressureAdjustable 必须是布尔值' })
|
|
||||||
isPressureAdjustable?: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '植入物备注',
|
description: '植入物备注',
|
||||||
example: '适用于儿童脑积水病例',
|
example: '适用于儿童脑积水病例',
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export class TaskEventsListener {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任务发布事件:通知创建医生与指定工程师(如有)。
|
* 任务发布事件:通知创建人和已绑定 openId 的接收工程师(如有)。
|
||||||
*/
|
*/
|
||||||
@OnEvent('task.published', { async: true })
|
@OnEvent('task.published', { async: true })
|
||||||
async onTaskPublished(payload: TaskEventPayload) {
|
async onTaskPublished(payload: TaskEventPayload) {
|
||||||
@ -54,6 +54,14 @@ export class TaskEventsListener {
|
|||||||
await this.dispatchTaskEvent('task.cancelled', payload);
|
await this.dispatchTaskEvent('task.cancelled', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工程师取消接收事件。
|
||||||
|
*/
|
||||||
|
@OnEvent('task.released', { async: true })
|
||||||
|
async onTaskReleased(payload: TaskEventPayload) {
|
||||||
|
await this.dispatchTaskEvent('task.released', payload);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一处理任务事件并派发通知目标。
|
* 统一处理任务事件并派发通知目标。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const IMPLANT_CATALOG_SELECT = {
|
|||||||
modelCode: true,
|
modelCode: true,
|
||||||
manufacturer: true,
|
manufacturer: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
isValve: true,
|
||||||
pressureLevels: true,
|
pressureLevels: true,
|
||||||
isPressureAdjustable: true,
|
isPressureAdjustable: true,
|
||||||
notes: true,
|
notes: true,
|
||||||
@ -43,6 +44,7 @@ const PATIENT_LIST_INCLUDE = {
|
|||||||
implantModel: true,
|
implantModel: true,
|
||||||
implantManufacturer: true,
|
implantManufacturer: true,
|
||||||
implantName: true,
|
implantName: true,
|
||||||
|
isValve: true,
|
||||||
isPressureAdjustable: true,
|
isPressureAdjustable: true,
|
||||||
},
|
},
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
@ -627,7 +629,7 @@ export class BPatientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialPressure =
|
const initialPressure =
|
||||||
device.initialPressure == null
|
!catalog.isValve || device.initialPressure == null
|
||||||
? null
|
? null
|
||||||
: this.assertPressureLevelAllowed(
|
: this.assertPressureLevelAllowed(
|
||||||
catalog,
|
catalog,
|
||||||
@ -637,10 +639,10 @@ export class BPatientsService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const fallbackPressureLevel =
|
const fallbackPressureLevel =
|
||||||
catalog.isPressureAdjustable && catalog.pressureLevels.length > 0
|
catalog.isValve && catalog.pressureLevels.length > 0
|
||||||
? catalog.pressureLevels[0]
|
? catalog.pressureLevels[0]
|
||||||
: '0';
|
: '0';
|
||||||
const currentPressure = catalog.isPressureAdjustable
|
const currentPressure = catalog.isValve
|
||||||
? this.assertPressureLevelAllowed(
|
? this.assertPressureLevelAllowed(
|
||||||
catalog,
|
catalog,
|
||||||
initialPressure ?? fallbackPressureLevel,
|
initialPressure ?? fallbackPressureLevel,
|
||||||
@ -655,6 +657,7 @@ export class BPatientsService {
|
|||||||
implantModel: catalog.modelCode,
|
implantModel: catalog.modelCode,
|
||||||
implantManufacturer: catalog.manufacturer,
|
implantManufacturer: catalog.manufacturer,
|
||||||
implantName: catalog.name,
|
implantName: catalog.name,
|
||||||
|
isValve: catalog.isValve,
|
||||||
isPressureAdjustable: catalog.isPressureAdjustable,
|
isPressureAdjustable: catalog.isPressureAdjustable,
|
||||||
isAbandoned: false,
|
isAbandoned: false,
|
||||||
shuntMode: this.normalizeRequiredString(device.shuntMode, 'shuntMode'),
|
shuntMode: this.normalizeRequiredString(device.shuntMode, 'shuntMode'),
|
||||||
@ -662,10 +665,15 @@ export class BPatientsService {
|
|||||||
device.proximalPunctureAreas,
|
device.proximalPunctureAreas,
|
||||||
'proximalPunctureAreas',
|
'proximalPunctureAreas',
|
||||||
),
|
),
|
||||||
valvePlacementSites: this.normalizeStringArray(
|
valvePlacementSites: catalog.isValve
|
||||||
device.valvePlacementSites,
|
? this.normalizeStringArray(
|
||||||
'valvePlacementSites',
|
device.valvePlacementSites,
|
||||||
),
|
'valvePlacementSites',
|
||||||
|
)
|
||||||
|
: this.normalizeOptionalStringArray(
|
||||||
|
device.valvePlacementSites,
|
||||||
|
'valvePlacementSites',
|
||||||
|
),
|
||||||
distalShuntDirection: this.normalizeRequiredString(
|
distalShuntDirection: this.normalizeRequiredString(
|
||||||
device.distalShuntDirection,
|
device.distalShuntDirection,
|
||||||
'distalShuntDirection',
|
'distalShuntDirection',
|
||||||
@ -780,12 +788,14 @@ export class BPatientsService {
|
|||||||
*/
|
*/
|
||||||
private assertPressureLevelAllowed(
|
private assertPressureLevelAllowed(
|
||||||
catalog: {
|
catalog: {
|
||||||
|
isValve: boolean;
|
||||||
isPressureAdjustable: boolean;
|
isPressureAdjustable: boolean;
|
||||||
pressureLevels: string[];
|
pressureLevels: string[];
|
||||||
},
|
},
|
||||||
pressure: string,
|
pressure: string,
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
|
catalog.isValve &&
|
||||||
catalog.isPressureAdjustable &&
|
catalog.isPressureAdjustable &&
|
||||||
Array.isArray(catalog.pressureLevels) &&
|
Array.isArray(catalog.pressureLevels) &&
|
||||||
catalog.pressureLevels.length > 0 &&
|
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(
|
private normalizePreOpMaterials(
|
||||||
materials: CreatePatientSurgeryDto['preOpMaterials'],
|
materials: CreatePatientSurgeryDto['preOpMaterials'],
|
||||||
): Prisma.InputJsonArray {
|
): Prisma.InputJsonArray {
|
||||||
|
|||||||
@ -42,6 +42,7 @@ export class CPatientsService {
|
|||||||
modelCode: true,
|
modelCode: true,
|
||||||
manufacturer: true,
|
manufacturer: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
isValve: true,
|
||||||
pressureLevels: true,
|
pressureLevels: true,
|
||||||
isPressureAdjustable: true,
|
isPressureAdjustable: true,
|
||||||
notes: true,
|
notes: true,
|
||||||
@ -67,6 +68,7 @@ export class CPatientsService {
|
|||||||
modelCode: true,
|
modelCode: true,
|
||||||
manufacturer: true,
|
manufacturer: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
isValve: true,
|
||||||
pressureLevels: true,
|
pressureLevels: true,
|
||||||
isPressureAdjustable: true,
|
isPressureAdjustable: true,
|
||||||
notes: true,
|
notes: true,
|
||||||
@ -117,6 +119,7 @@ export class CPatientsService {
|
|||||||
implantModel: device.implantModel,
|
implantModel: device.implantModel,
|
||||||
implantManufacturer: device.implantManufacturer,
|
implantManufacturer: device.implantManufacturer,
|
||||||
implantName: device.implantName,
|
implantName: device.implantName,
|
||||||
|
isValve: device.isValve,
|
||||||
isPressureAdjustable: device.isPressureAdjustable,
|
isPressureAdjustable: device.isPressureAdjustable,
|
||||||
shuntMode: device.shuntMode,
|
shuntMode: device.shuntMode,
|
||||||
distalShuntDirection: device.distalShuntDirection,
|
distalShuntDirection: device.distalShuntDirection,
|
||||||
@ -154,6 +157,7 @@ export class CPatientsService {
|
|||||||
implantModel: device.implantModel,
|
implantModel: device.implantModel,
|
||||||
implantManufacturer: device.implantManufacturer,
|
implantManufacturer: device.implantManufacturer,
|
||||||
implantName: device.implantName,
|
implantName: device.implantName,
|
||||||
|
isValve: device.isValve,
|
||||||
isPressureAdjustable: device.isPressureAdjustable,
|
isPressureAdjustable: device.isPressureAdjustable,
|
||||||
},
|
},
|
||||||
surgery: device.surgery
|
surgery: device.surgery
|
||||||
|
|||||||
@ -41,16 +41,16 @@ export class CreateSurgeryDeviceDto {
|
|||||||
@IsString({ each: true, message: 'proximalPunctureAreas 必须为字符串数组' })
|
@IsString({ each: true, message: 'proximalPunctureAreas 必须为字符串数组' })
|
||||||
proximalPunctureAreas!: string[];
|
proximalPunctureAreas!: string[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiPropertyOptional({
|
||||||
description: '阀门植入部位,最多 2 个',
|
description: '阀门植入部位,阀门型植入物最多 2 个',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['耳后', '胸前'],
|
example: ['耳后', '胸前'],
|
||||||
})
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsArray({ message: 'valvePlacementSites 必须是数组' })
|
@IsArray({ message: 'valvePlacementSites 必须是数组' })
|
||||||
@ArrayMinSize(1, { message: 'valvePlacementSites 至少选择 1 项' })
|
|
||||||
@ArrayMaxSize(2, { message: 'valvePlacementSites 最多选择 2 项' })
|
@ArrayMaxSize(2, { message: 'valvePlacementSites 最多选择 2 项' })
|
||||||
@IsString({ each: true, message: 'valvePlacementSites 必须为字符串数组' })
|
@IsString({ each: true, message: 'valvePlacementSites 必须为字符串数组' })
|
||||||
valvePlacementSites!: string[];
|
valvePlacementSites?: string[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: '远端分流方向',
|
description: '远端分流方向',
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export class BTasksController {
|
|||||||
constructor(private readonly taskService: TaskService) {}
|
constructor(private readonly taskService: TaskService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询当前角色可指定的接收工程师列表。
|
* 查询当前角色可见的医院工程师列表。
|
||||||
*/
|
*/
|
||||||
@Get('engineers')
|
@Get('engineers')
|
||||||
@Roles(
|
@Roles(
|
||||||
@ -40,7 +40,7 @@ export class BTasksController {
|
|||||||
Role.DIRECTOR,
|
Role.DIRECTOR,
|
||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
)
|
)
|
||||||
@ApiOperation({ summary: '查询可选接收工程师列表' })
|
@ApiOperation({ summary: '查询医院工程师列表' })
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: 'hospitalId',
|
name: 'hospitalId',
|
||||||
required: false,
|
required: false,
|
||||||
@ -97,11 +97,11 @@ export class BTasksController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工程师接收调压任务(当前流程已停用)。
|
* 工程师接收调压任务。
|
||||||
*/
|
*/
|
||||||
@Post('accept')
|
@Post('accept')
|
||||||
@Roles(Role.ENGINEER)
|
@Roles(Role.ENGINEER)
|
||||||
@ApiOperation({ summary: '接收任务(已停用)' })
|
@ApiOperation({ summary: '接收任务(ENGINEER)' })
|
||||||
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
|
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
|
||||||
return this.taskService.acceptTask(actor, dto);
|
return this.taskService.acceptTask(actor, dto);
|
||||||
}
|
}
|
||||||
@ -117,7 +117,7 @@ export class BTasksController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 系统管理员/医院管理员/医生/主任/组长取消调压任务(仅任务创建者)。
|
* 系统管理员/医院管理员/医生/主任/组长可取消自己创建的任务;工程师可取消自己已接收的任务。
|
||||||
*/
|
*/
|
||||||
@Post('cancel')
|
@Post('cancel')
|
||||||
@Roles(
|
@Roles(
|
||||||
@ -126,9 +126,11 @@ export class BTasksController {
|
|||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
Role.DIRECTOR,
|
Role.DIRECTOR,
|
||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
|
Role.ENGINEER,
|
||||||
)
|
)
|
||||||
@ApiOperation({
|
@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) {
|
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
|
||||||
return this.taskService.cancelTask(actor, dto);
|
return this.taskService.cancelTask(actor, dto);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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。
|
* 完成任务 DTO。
|
||||||
@ -11,4 +12,14 @@ export class CompleteTaskDto {
|
|||||||
@IsInt({ message: 'taskId 必须是整数' })
|
@IsInt({ message: 'taskId 必须是整数' })
|
||||||
@Min(1, { message: 'taskId 必须大于 0' })
|
@Min(1, { message: 'taskId 必须大于 0' })
|
||||||
taskId!: number;
|
taskId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: [TaskCompletionMaterialDto],
|
||||||
|
description: '完成任务时上传的图片/视频凭证',
|
||||||
|
})
|
||||||
|
@IsArray({ message: 'completionMaterials 必须是数组' })
|
||||||
|
@ArrayMinSize(1, { message: 'completionMaterials 至少上传 1 项' })
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => TaskCompletionMaterialDto)
|
||||||
|
completionMaterials!: TaskCompletionMaterialDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,12 +28,6 @@ export class PublishTaskItemDto {
|
|||||||
* 发布任务 DTO。
|
* 发布任务 DTO。
|
||||||
*/
|
*/
|
||||||
export class PublishTaskDto {
|
export class PublishTaskDto {
|
||||||
@ApiProperty({ description: '接收工程师 ID', example: 2 })
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt({ message: 'engineerId 必须是整数' })
|
|
||||||
@Min(1, { message: 'engineerId 必须大于 0' })
|
|
||||||
engineerId!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
|
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
|
||||||
@IsArray({ message: 'items 必须是数组' })
|
@IsArray({ message: 'items 必须是数组' })
|
||||||
@ArrayMinSize(1, { message: 'items 至少包含一条明细' })
|
@ArrayMinSize(1, { message: 'items 至少包含一条明细' })
|
||||||
|
|||||||
42
src/tasks/dto/task-completion-material.dto.ts
Normal file
42
src/tasks/dto/task-completion-material.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -7,7 +7,12 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { Prisma } from '../generated/prisma/client.js';
|
import { 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 { PrismaService } from '../prisma.service.js';
|
||||||
import type { ActorContext } from '../common/actor-context.js';
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
import { PublishTaskDto } from './dto/publish-task.dto.js';
|
import { PublishTaskDto } from './dto/publish-task.dto.js';
|
||||||
@ -29,7 +34,7 @@ export class TaskService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询当前角色可指定的接收工程师列表。
|
* 查询当前角色可见的医院工程师列表。
|
||||||
*/
|
*/
|
||||||
async findAssignableEngineers(
|
async findAssignableEngineers(
|
||||||
actor: ActorContext,
|
actor: ActorContext,
|
||||||
@ -97,6 +102,7 @@ export class TaskService {
|
|||||||
id: true,
|
id: true,
|
||||||
status: true,
|
status: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
completionMaterials: true,
|
||||||
hospital: {
|
hospital: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@ -156,6 +162,9 @@ export class TaskService {
|
|||||||
taskId: item.task.id,
|
taskId: item.task.id,
|
||||||
status: item.task.status,
|
status: item.task.status,
|
||||||
createdAt: item.task.createdAt,
|
createdAt: item.task.createdAt,
|
||||||
|
completionMaterials: Array.isArray(item.task.completionMaterials)
|
||||||
|
? item.task.completionMaterials
|
||||||
|
: [],
|
||||||
hospital: item.task.hospital,
|
hospital: item.task.hospital,
|
||||||
creator: item.task.creator,
|
creator: item.task.creator,
|
||||||
engineer: item.task.engineer,
|
engineer: item.task.engineer,
|
||||||
@ -175,7 +184,7 @@ export class TaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布任务:管理员或临床角色创建主任务与明细,并直接指定接收工程师。
|
* 发布任务:管理员或临床角色创建主任务与明细,等待本院工程师接收。
|
||||||
*/
|
*/
|
||||||
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
|
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
|
||||||
this.assertRole(actor, [
|
this.assertRole(actor, [
|
||||||
@ -238,18 +247,7 @@ export class TaskService {
|
|||||||
actor,
|
actor,
|
||||||
devices.map((device) => device.patient.hospitalId),
|
devices.map((device) => device.patient.hospitalId),
|
||||||
);
|
);
|
||||||
|
await this.assertNoDuplicateOpenTaskForDevices(deviceIds);
|
||||||
const engineer = await this.prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
id: dto.engineerId,
|
|
||||||
role: Role.ENGINEER,
|
|
||||||
hospitalId,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!engineer) {
|
|
||||||
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pressureByDeviceId = new Map(
|
const pressureByDeviceId = new Map(
|
||||||
devices.map((device) => [device.id, device.currentPressure] as const),
|
devices.map((device) => [device.id, device.currentPressure] as const),
|
||||||
@ -280,9 +278,8 @@ export class TaskService {
|
|||||||
|
|
||||||
const task = await this.prisma.task.create({
|
const task = await this.prisma.task.create({
|
||||||
data: {
|
data: {
|
||||||
status: TaskStatus.ACCEPTED,
|
status: TaskStatus.PENDING,
|
||||||
creatorId: actor.id,
|
creatorId: actor.id,
|
||||||
engineerId: engineer.id,
|
|
||||||
hospitalId,
|
hospitalId,
|
||||||
items: {
|
items: {
|
||||||
create: dto.items.map((item) => ({
|
create: dto.items.map((item) => ({
|
||||||
@ -306,10 +303,77 @@ export class TaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 接收任务:当前流程已停用,统一改为发布时直接指定接收工程师。
|
* 接收任务:工程师接收本院待处理任务,任务一旦被接收不可重复抢单。
|
||||||
*/
|
*/
|
||||||
async acceptTask(_actor: ActorContext, _dto: AcceptTaskDto) {
|
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
|
||||||
throw new ForbiddenException(MESSAGES.TASK.ACCEPT_DISABLED);
|
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) {
|
async completeTask(actor: ActorContext, dto: CompleteTaskDto) {
|
||||||
this.assertRole(actor, [Role.ENGINEER]);
|
this.assertRole(actor, [Role.ENGINEER]);
|
||||||
const hospitalId = this.requireHospitalId(actor);
|
const hospitalId = this.requireHospitalId(actor);
|
||||||
|
const completionMaterials = await this.normalizeCompletionMaterials(
|
||||||
|
hospitalId,
|
||||||
|
dto.completionMaterials,
|
||||||
|
);
|
||||||
|
|
||||||
const task = await this.prisma.task.findFirst({
|
const task = await this.prisma.task.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@ -342,7 +410,10 @@ export class TaskService {
|
|||||||
const completedTask = await this.prisma.$transaction(async (tx) => {
|
const completedTask = await this.prisma.$transaction(async (tx) => {
|
||||||
const nextTask = await tx.task.update({
|
const nextTask = await tx.task.update({
|
||||||
where: { id: task.id },
|
where: { id: task.id },
|
||||||
data: { status: TaskStatus.COMPLETED },
|
data: {
|
||||||
|
status: TaskStatus.COMPLETED,
|
||||||
|
completionMaterials,
|
||||||
|
},
|
||||||
include: { items: true },
|
include: { items: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -369,7 +440,9 @@ export class TaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消任务:任务创建者可将 PENDING/ACCEPTED 任务取消。
|
* 取消任务:
|
||||||
|
* 1. 创建者可将 PENDING/ACCEPTED 任务真正取消为 CANCELLED;
|
||||||
|
* 2. 接收工程师可将自己已接收任务退回为 PENDING,供其他工程师重新接收。
|
||||||
*/
|
*/
|
||||||
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
||||||
this.assertRole(actor, [
|
this.assertRole(actor, [
|
||||||
@ -378,6 +451,7 @@ export class TaskService {
|
|||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
Role.DIRECTOR,
|
Role.DIRECTOR,
|
||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
|
Role.ENGINEER,
|
||||||
]);
|
]);
|
||||||
const scopedHospitalId = this.resolveScopedHospitalId(actor);
|
const scopedHospitalId = this.resolveScopedHospitalId(actor);
|
||||||
|
|
||||||
@ -390,6 +464,7 @@ export class TaskService {
|
|||||||
id: true,
|
id: true,
|
||||||
status: true,
|
status: true,
|
||||||
creatorId: true,
|
creatorId: true,
|
||||||
|
engineerId: true,
|
||||||
hospitalId: true,
|
hospitalId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -397,7 +472,33 @@ export class TaskService {
|
|||||||
if (!task) {
|
if (!task) {
|
||||||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
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);
|
throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_CREATOR);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@ -581,6 +682,74 @@ export class TaskService {
|
|||||||
return normalizePressureLabel(value, 'targetPressure');
|
return normalizePressureLabel(value, 'targetPressure');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成任务凭证标准化:仅允许当前医院下的图片/视频上传资产。
|
||||||
|
*/
|
||||||
|
private async normalizeCompletionMaterials(
|
||||||
|
hospitalId: number,
|
||||||
|
materials: CompleteTaskDto['completionMaterials'],
|
||||||
|
): Promise<Prisma.InputJsonArray> {
|
||||||
|
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;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,10 @@ import type { ActorContext } from '../../common/actor-context.js';
|
|||||||
import { Role } from '../../generated/prisma/enums.js';
|
import { Role } from '../../generated/prisma/enums.js';
|
||||||
import { MESSAGES } from '../../common/messages.js';
|
import { MESSAGES } from '../../common/messages.js';
|
||||||
import { UploadAssetQueryDto } from '../dto/upload-asset-query.dto.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 { UploadsService } from '../uploads.service.js';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname } from 'node:path';
|
import { extname } from 'node:path';
|
||||||
@ -65,6 +68,7 @@ export class BUploadsController {
|
|||||||
Role.DIRECTOR,
|
Role.DIRECTOR,
|
||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
|
Role.ENGINEER,
|
||||||
)
|
)
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
|
|||||||
@ -112,7 +112,12 @@ export async function ensureE2EFixtures(
|
|||||||
await bootstrapFixturesViaApi(app);
|
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) {
|
async function bootstrapFixturesViaApi(app: INestApplication) {
|
||||||
@ -138,14 +143,19 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
|
|||||||
{ name: FIXTURE_NAMES.hospitalB },
|
{ name: FIXTURE_NAMES.hospitalB },
|
||||||
);
|
);
|
||||||
|
|
||||||
const hospitalAdminA = await createWithToken(server, systemAdminToken, '/users', {
|
const hospitalAdminA = await createWithToken(
|
||||||
name: 'Seed Hospital Admin A',
|
server,
|
||||||
phone: E2E_SEED_CREDENTIALS[Role.HOSPITAL_ADMIN].phone,
|
systemAdminToken,
|
||||||
password: E2E_SEED_PASSWORD,
|
'/users',
|
||||||
role: Role.HOSPITAL_ADMIN,
|
{
|
||||||
hospitalId: hospitalA.id,
|
name: 'Seed Hospital Admin A',
|
||||||
openId: OPEN_IDS.hospitalAdminA,
|
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', {
|
await createWithToken(server, systemAdminToken, '/users', {
|
||||||
name: 'Seed Hospital Admin B',
|
name: 'Seed Hospital Admin B',
|
||||||
phone: EXTRA_PHONES.hospitalAdminB,
|
phone: EXTRA_PHONES.hospitalAdminB,
|
||||||
@ -254,15 +264,20 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const directorA = await createWithToken(server, hospitalAdminAToken, '/users', {
|
const directorA = await createWithToken(
|
||||||
name: 'Seed Director A',
|
server,
|
||||||
phone: E2E_SEED_CREDENTIALS[Role.DIRECTOR].phone,
|
hospitalAdminAToken,
|
||||||
password: E2E_SEED_PASSWORD,
|
'/users',
|
||||||
role: Role.DIRECTOR,
|
{
|
||||||
hospitalId: hospitalA.id,
|
name: 'Seed Director A',
|
||||||
departmentId: departmentA1.id,
|
phone: E2E_SEED_CREDENTIALS[Role.DIRECTOR].phone,
|
||||||
openId: OPEN_IDS.directorA,
|
password: E2E_SEED_PASSWORD,
|
||||||
});
|
role: Role.DIRECTOR,
|
||||||
|
hospitalId: hospitalA.id,
|
||||||
|
departmentId: departmentA1.id,
|
||||||
|
openId: OPEN_IDS.directorA,
|
||||||
|
},
|
||||||
|
);
|
||||||
await createWithToken(server, hospitalAdminAToken, '/users', {
|
await createWithToken(server, hospitalAdminAToken, '/users', {
|
||||||
name: 'Seed Leader A',
|
name: 'Seed Leader A',
|
||||||
phone: E2E_SEED_CREDENTIALS[Role.LEADER].phone,
|
phone: E2E_SEED_CREDENTIALS[Role.LEADER].phone,
|
||||||
@ -283,26 +298,36 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
|
|||||||
groupId: groupA1.id,
|
groupId: groupA1.id,
|
||||||
openId: OPEN_IDS.doctorA,
|
openId: OPEN_IDS.doctorA,
|
||||||
});
|
});
|
||||||
const doctorA2 = await createWithToken(server, hospitalAdminAToken, '/users', {
|
const doctorA2 = await createWithToken(
|
||||||
name: 'Seed Doctor A2',
|
server,
|
||||||
phone: EXTRA_PHONES.doctorA2,
|
hospitalAdminAToken,
|
||||||
password: E2E_SEED_PASSWORD,
|
'/users',
|
||||||
role: Role.DOCTOR,
|
{
|
||||||
hospitalId: hospitalA.id,
|
name: 'Seed Doctor A2',
|
||||||
departmentId: departmentA1.id,
|
phone: EXTRA_PHONES.doctorA2,
|
||||||
groupId: groupA1.id,
|
password: E2E_SEED_PASSWORD,
|
||||||
openId: OPEN_IDS.doctorA2,
|
role: Role.DOCTOR,
|
||||||
});
|
hospitalId: hospitalA.id,
|
||||||
const doctorA3 = await createWithToken(server, hospitalAdminAToken, '/users', {
|
departmentId: departmentA1.id,
|
||||||
name: 'Seed Doctor A3',
|
groupId: groupA1.id,
|
||||||
phone: EXTRA_PHONES.doctorA3,
|
openId: OPEN_IDS.doctorA2,
|
||||||
password: E2E_SEED_PASSWORD,
|
},
|
||||||
role: Role.DOCTOR,
|
);
|
||||||
hospitalId: hospitalA.id,
|
const doctorA3 = await createWithToken(
|
||||||
departmentId: departmentA2.id,
|
server,
|
||||||
groupId: groupA2.id,
|
hospitalAdminAToken,
|
||||||
openId: OPEN_IDS.doctorA3,
|
'/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', {
|
const doctorB = await createWithToken(server, hospitalAdminBToken, '/users', {
|
||||||
name: 'Seed Doctor B',
|
name: 'Seed Doctor B',
|
||||||
phone: EXTRA_PHONES.doctorB,
|
phone: EXTRA_PHONES.doctorB,
|
||||||
@ -324,8 +349,8 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
|
|||||||
modelCode: FIXTURE_NAMES.adjustableCatalog,
|
modelCode: FIXTURE_NAMES.adjustableCatalog,
|
||||||
manufacturer: 'Seed MedTech',
|
manufacturer: 'Seed MedTech',
|
||||||
name: 'Seed 可调压分流阀',
|
name: 'Seed 可调压分流阀',
|
||||||
|
isValve: true,
|
||||||
pressureLevels: ['0.5', '1', '1.5'],
|
pressureLevels: ['0.5', '1', '1.5'],
|
||||||
isPressureAdjustable: true,
|
|
||||||
notes: 'Seed 全局可调压目录样例',
|
notes: 'Seed 全局可调压目录样例',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -333,8 +358,8 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
|
|||||||
modelCode: FIXTURE_NAMES.highPressureCatalog,
|
modelCode: FIXTURE_NAMES.highPressureCatalog,
|
||||||
manufacturer: 'Seed MedTech',
|
manufacturer: 'Seed MedTech',
|
||||||
name: 'Seed 高压挡位阀',
|
name: 'Seed 高压挡位阀',
|
||||||
|
isValve: true,
|
||||||
pressureLevels: ['10', '20', '30'],
|
pressureLevels: ['10', '20', '30'],
|
||||||
isPressureAdjustable: true,
|
|
||||||
notes: 'Seed 高压挡位目录样例',
|
notes: 'Seed 高压挡位目录样例',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -438,33 +463,38 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const patientA2 = await createWithToken(server, doctorA2Token, '/b/patients', {
|
const patientA2 = await createWithToken(
|
||||||
name: 'Seed Patient A2',
|
server,
|
||||||
inpatientNo: 'ZYH-A-0002',
|
doctorA2Token,
|
||||||
projectName: '脑积水随访项目-A',
|
'/b/patients',
|
||||||
phone: '13800002002',
|
{
|
||||||
idCard: '110101199002020022',
|
name: 'Seed Patient A2',
|
||||||
doctorId: doctorA2.id,
|
inpatientNo: 'ZYH-A-0002',
|
||||||
initialSurgery: {
|
projectName: '脑积水随访项目-A',
|
||||||
surgeryDate: '2025-12-15T08:00:00.000Z',
|
phone: '13800002002',
|
||||||
surgeryName: '脑室腹腔分流术',
|
idCard: '110101199002020022',
|
||||||
preOpPressure: 20,
|
doctorId: doctorA2.id,
|
||||||
primaryDisease: '肿瘤相关脑积水',
|
initialSurgery: {
|
||||||
hydrocephalusTypes: ['梗阻性'],
|
surgeryDate: '2025-12-15T08:00:00.000Z',
|
||||||
devices: [
|
surgeryName: '脑室腹腔分流术',
|
||||||
{
|
preOpPressure: 20,
|
||||||
implantCatalogId: adjustableCatalog.id,
|
primaryDisease: '肿瘤相关脑积水',
|
||||||
shuntMode: 'VPS',
|
hydrocephalusTypes: ['梗阻性'],
|
||||||
proximalPunctureAreas: ['枕角'],
|
devices: [
|
||||||
valvePlacementSites: ['胸前'],
|
{
|
||||||
distalShuntDirection: '腹腔',
|
implantCatalogId: adjustableCatalog.id,
|
||||||
initialPressure: '1',
|
shuntMode: 'VPS',
|
||||||
implantNotes: 'Seed A2 当前在用设备',
|
proximalPunctureAreas: ['枕角'],
|
||||||
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
|
valvePlacementSites: ['胸前'],
|
||||||
},
|
distalShuntDirection: '腹腔',
|
||||||
],
|
initialPressure: '1',
|
||||||
|
implantNotes: 'Seed A2 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
await createWithToken(server, doctorA3Token, '/b/patients', {
|
await createWithToken(server, doctorA3Token, '/b/patients', {
|
||||||
name: 'Seed Patient A3',
|
name: 'Seed Patient A3',
|
||||||
@ -533,7 +563,6 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
|
|||||||
doctorAToken,
|
doctorAToken,
|
||||||
'/b/tasks/publish',
|
'/b/tasks/publish',
|
||||||
{
|
{
|
||||||
engineerId: engineerA.id,
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId: deviceA1Id,
|
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', {
|
await createWithToken(server, engineerAToken, '/b/tasks/complete', {
|
||||||
taskId: publishedA.id,
|
taskId: publishedA.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await createWithToken(server, doctorBToken, '/b/tasks/publish', {
|
await createWithToken(server, doctorBToken, '/b/tasks/publish', {
|
||||||
engineerId: engineerB.id,
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId: deviceB1Id,
|
deviceId: deviceB1Id,
|
||||||
@ -561,6 +593,325 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
|
|||||||
void patientA2;
|
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(
|
async function bootstrapDictionaries(
|
||||||
server: ReturnType<INestApplication['getHttpServer']>,
|
server: ReturnType<INestApplication['getHttpServer']>,
|
||||||
systemAdminToken: string,
|
systemAdminToken: string,
|
||||||
@ -693,6 +1044,56 @@ async function requireCatalogId(
|
|||||||
return catalog.id;
|
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<INestApplication['getHttpServer']>,
|
||||||
|
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(
|
async function requireDeviceId(
|
||||||
prisma: PrismaService,
|
prisma: PrismaService,
|
||||||
implantNotes: string,
|
implantNotes: string,
|
||||||
@ -727,7 +1128,10 @@ export async function loadSeedFixtures(
|
|||||||
prisma: PrismaService,
|
prisma: PrismaService,
|
||||||
): Promise<E2ESeedFixtures> {
|
): Promise<E2ESeedFixtures> {
|
||||||
const systemAdmin = await requireUserScope(prisma, OPEN_IDS.systemAdmin);
|
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 directorA = await requireUserScope(prisma, OPEN_IDS.directorA);
|
||||||
const leaderA = await requireUserScope(prisma, OPEN_IDS.leaderA);
|
const leaderA = await requireUserScope(prisma, OPEN_IDS.leaderA);
|
||||||
const doctorA = await requireUserScope(prisma, OPEN_IDS.doctorA);
|
const doctorA = await requireUserScope(prisma, OPEN_IDS.doctorA);
|
||||||
|
|||||||
@ -115,13 +115,18 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
modelCode: uniqueSeedValue('catalog').toUpperCase(),
|
modelCode: uniqueSeedValue('catalog').toUpperCase(),
|
||||||
manufacturer: 'Global Vendor',
|
manufacturer: 'Global Vendor',
|
||||||
name: '全局可调压阀',
|
name: '全局可调压阀',
|
||||||
isPressureAdjustable: true,
|
isValve: true,
|
||||||
pressureLevels: ['10.0', '20', '30.0'],
|
pressureLevels: ['10.0', '20', '30.0'],
|
||||||
notes: '测试全局目录',
|
notes: '测试全局目录',
|
||||||
});
|
});
|
||||||
|
|
||||||
expectSuccessEnvelope(createResponse, 201);
|
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())
|
const updateResponse = await request(ctx.app.getHttpServer())
|
||||||
.patch(`/b/devices/catalogs/${createResponse.body.data.id}`)
|
.patch(`/b/devices/catalogs/${createResponse.body.data.id}`)
|
||||||
@ -133,6 +138,7 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
|
|
||||||
expectSuccessEnvelope(updateResponse, 200);
|
expectSuccessEnvelope(updateResponse, 200);
|
||||||
expect(updateResponse.body.data.name).toBe('全局可调压阀-更新版');
|
expect(updateResponse.body.data.name).toBe('全局可调压阀-更新版');
|
||||||
|
expect(updateResponse.body.data.isValve).toBe(true);
|
||||||
expect(updateResponse.body.data.pressureLevels).toEqual([
|
expect(updateResponse.body.data.pressureLevels).toEqual([
|
||||||
'0.5',
|
'0.5',
|
||||||
'1',
|
'1',
|
||||||
@ -147,6 +153,24 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
expect(deleteResponse.body.data.id).toBe(createResponse.body.data.id);
|
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 () => {
|
it('角色矩阵:仅 SYSTEM_ADMIN 可维护全局植入物目录', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/devices/catalogs role matrix',
|
name: 'POST /b/devices/catalogs role matrix',
|
||||||
@ -167,7 +191,7 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
modelCode: uniqueSeedValue('catalog-role').toUpperCase(),
|
modelCode: uniqueSeedValue('catalog-role').toUpperCase(),
|
||||||
manufacturer: 'Role Matrix Vendor',
|
manufacturer: 'Role Matrix Vendor',
|
||||||
name: '角色矩阵目录',
|
name: '角色矩阵目录',
|
||||||
isPressureAdjustable: true,
|
isValve: true,
|
||||||
pressureLevels: ['10', '20'],
|
pressureLevels: ['10', '20'],
|
||||||
}),
|
}),
|
||||||
sendWithoutToken: async () =>
|
sendWithoutToken: async () =>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { DeviceStatus, Role } from '../../../src/generated/prisma/enums.js';
|
import { DeviceStatus, Role } from '../../../src/generated/prisma/enums.js';
|
||||||
import {
|
import {
|
||||||
@ -22,15 +23,45 @@ function uniqueIdCard() {
|
|||||||
|
|
||||||
describe('Patients Controllers (e2e)', () => {
|
describe('Patients Controllers (e2e)', () => {
|
||||||
let ctx: E2EContext;
|
let ctx: E2EContext;
|
||||||
|
let samplePngBuffer: Buffer;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
ctx = await createE2EContext();
|
ctx = await createE2EContext();
|
||||||
|
samplePngBuffer = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
channels: 3,
|
||||||
|
background: { r: 20, g: 60, b: 120 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await closeE2EContext(ctx);
|
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', () => {
|
describe('GET /b/patients', () => {
|
||||||
it('成功:按角色返回正确可见性范围', async () => {
|
it('成功:按角色返回正确可见性范围', async () => {
|
||||||
const systemAdminResponse = await request(ctx.app.getHttpServer())
|
const systemAdminResponse = await request(ctx.app.getHttpServer())
|
||||||
@ -292,7 +323,6 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
.post('/b/tasks/publish')
|
.post('/b/tasks/publish')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
.send({
|
.send({
|
||||||
engineerId: ctx.fixtures.users.engineerAId,
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId: oldDeviceId,
|
deviceId: oldDeviceId,
|
||||||
@ -302,10 +332,27 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
});
|
});
|
||||||
expectSuccessEnvelope(publishResponse, 201);
|
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())
|
const completeResponse = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/complete')
|
.post('/b/tasks/complete')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
.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);
|
expectSuccessEnvelope(completeResponse, 201);
|
||||||
|
|
||||||
const surgeryResponse = await request(ctx.app.getHttpServer())
|
const surgeryResponse = await request(ctx.app.getHttpServer())
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { Role, TaskStatus } from '../../../src/generated/prisma/enums.js';
|
import { Role, TaskStatus } from '../../../src/generated/prisma/enums.js';
|
||||||
import {
|
import {
|
||||||
@ -9,30 +10,54 @@ import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
|
|||||||
import {
|
import {
|
||||||
expectErrorEnvelope,
|
expectErrorEnvelope,
|
||||||
expectSuccessEnvelope,
|
expectSuccessEnvelope,
|
||||||
|
uniquePhone,
|
||||||
|
uniqueSeedValue,
|
||||||
} from '../helpers/e2e-http.helper.js';
|
} 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)', () => {
|
describe('BTasksController (e2e)', () => {
|
||||||
let ctx: E2EContext;
|
let ctx: E2EContext;
|
||||||
|
let samplePngBuffer: Buffer;
|
||||||
|
let doctorBToken = '';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
ctx = await createE2EContext();
|
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 () => {
|
afterAll(async () => {
|
||||||
await closeE2EContext(ctx);
|
await closeE2EContext(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function publishAssignedTask(
|
async function publishPendingTask(
|
||||||
deviceId: number,
|
deviceId: number,
|
||||||
targetPressure: string,
|
targetPressure: string,
|
||||||
actorToken = ctx.tokens[Role.DOCTOR],
|
actorToken = ctx.tokens[Role.DOCTOR],
|
||||||
engineerId = ctx.fixtures.users.engineerAId,
|
|
||||||
) {
|
) {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/publish')
|
.post('/b/tasks/publish')
|
||||||
.set('Authorization', `Bearer ${actorToken}`)
|
.set('Authorization', `Bearer ${actorToken}`)
|
||||||
.send({
|
.send({
|
||||||
engineerId,
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId,
|
deviceId,
|
||||||
@ -45,11 +70,131 @@ describe('BTasksController (e2e)', () => {
|
|||||||
return response.body.data as {
|
return response.body.data as {
|
||||||
id: number;
|
id: number;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
engineerId: number;
|
engineerId: number | null;
|
||||||
hospitalId: number;
|
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<ReturnType<typeof uploadEngineerProof>>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
completionMaterials: [
|
||||||
|
{
|
||||||
|
assetId: asset.id,
|
||||||
|
type: asset.type,
|
||||||
|
url: asset.url,
|
||||||
|
name: asset.originalName || asset.fileName || '调压完成照片',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('GET /b/tasks/engineers', () => {
|
describe('GET /b/tasks/engineers', () => {
|
||||||
it('成功:DOCTOR 可查看本院可选工程师', async () => {
|
it('成功:DOCTOR 可查看本院可选工程师', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
@ -94,6 +239,24 @@ describe('BTasksController (e2e)', () => {
|
|||||||
|
|
||||||
describe('GET /b/tasks', () => {
|
describe('GET /b/tasks', () => {
|
||||||
it('成功:SYSTEM_ADMIN 可查看跨医院调压记录', async () => {
|
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())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.get('/b/tasks')
|
.get('/b/tasks')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
@ -106,12 +269,9 @@ describe('BTasksController (e2e)', () => {
|
|||||||
response.body.data.list.every(
|
response.body.data.list.every(
|
||||||
(item: {
|
(item: {
|
||||||
creator?: { id?: number; name?: string };
|
creator?: { id?: number; name?: string };
|
||||||
engineer?: { id?: number; name?: string };
|
engineer?: { id?: number; name?: string } | null;
|
||||||
}) =>
|
}) =>
|
||||||
Number.isInteger(item.creator?.id) &&
|
Number.isInteger(item.creator?.id) && Boolean(item.creator?.name),
|
||||||
Boolean(item.creator?.name) &&
|
|
||||||
Number.isInteger(item.engineer?.id) &&
|
|
||||||
Boolean(item.engineer?.name),
|
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
@ -158,63 +318,46 @@ describe('BTasksController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /b/tasks/publish', () => {
|
describe('POST /b/tasks/publish', () => {
|
||||||
it('成功:DOCTOR 发布任务时必须直接指定接收工程师', async () => {
|
it('成功:DOCTOR 发布任务后进入待接收状态', async () => {
|
||||||
|
const [device] = await createAdjustableDevices();
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/publish')
|
.post('/b/tasks/publish')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
.send({
|
.send({
|
||||||
engineerId: ctx.fixtures.users.engineerAId,
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId: ctx.fixtures.devices.deviceA2Id,
|
deviceId: device.id,
|
||||||
targetPressure: '1.5',
|
targetPressure: '1.5',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 201);
|
expectSuccessEnvelope(response, 201);
|
||||||
expect(response.body.data.status).toBe(TaskStatus.ACCEPTED);
|
expect(response.body.data.status).toBe(TaskStatus.PENDING);
|
||||||
expect(response.body.data.engineerId).toBe(
|
expect(response.body.data.engineerId).toBeNull();
|
||||||
ctx.fixtures.users.engineerAId,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('成功:SYSTEM_ADMIN 可按设备自动归院发布任务', async () => {
|
it('成功:SYSTEM_ADMIN 可按设备自动归院发布任务', async () => {
|
||||||
const task = await publishAssignedTask(
|
const [device] = await createAdjustableDevices();
|
||||||
ctx.fixtures.devices.deviceA1Id,
|
const task = await publishPendingTask(
|
||||||
|
device.id,
|
||||||
'1.5',
|
'1.5',
|
||||||
ctx.tokens[Role.SYSTEM_ADMIN],
|
ctx.tokens[Role.SYSTEM_ADMIN],
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(task.status).toBe(TaskStatus.ACCEPTED);
|
expect(task.status).toBe(TaskStatus.PENDING);
|
||||||
expect(task.hospitalId).toBe(ctx.fixtures.hospitalAId);
|
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 () => {
|
it('失败:可调压设备使用非法挡位返回 400', async () => {
|
||||||
|
const [device] = await createAdjustableDevices();
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/publish')
|
.post('/b/tasks/publish')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
.send({
|
.send({
|
||||||
engineerId: ctx.fixtures.users.engineerAId,
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId: ctx.fixtures.devices.deviceA2Id,
|
deviceId: device.id,
|
||||||
targetPressure: '2',
|
targetPressure: '2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -228,7 +371,6 @@ describe('BTasksController (e2e)', () => {
|
|||||||
.post('/b/tasks/publish')
|
.post('/b/tasks/publish')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
.send({
|
.send({
|
||||||
engineerId: ctx.fixtures.users.engineerAId,
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId: ctx.fixtures.devices.deviceB1Id,
|
deviceId: ctx.fixtures.devices.deviceB1Id,
|
||||||
@ -240,6 +382,47 @@ describe('BTasksController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
|
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 () => {
|
it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/tasks/publish role matrix',
|
name: 'POST /b/tasks/publish role matrix',
|
||||||
@ -264,25 +447,56 @@ describe('BTasksController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /b/tasks/accept', () => {
|
describe('POST /b/tasks/accept', () => {
|
||||||
it('失败:ENGINEER 接收接口已停用,返回 403', async () => {
|
it('成功:ENGINEER 可接收本院待处理任务', async () => {
|
||||||
const task = await publishAssignedTask(
|
const [device] = await createAdjustableDevices();
|
||||||
ctx.fixtures.devices.deviceA2Id,
|
const task = await publishPendingTask(device.id, '1.5');
|
||||||
'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())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/accept')
|
.post('/b/tasks/accept')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
.send({ taskId: task.id });
|
.send({ taskId: task.id });
|
||||||
|
|
||||||
expectErrorEnvelope(
|
expectErrorEnvelope(response, 409, '仅待接收任务可执行接收');
|
||||||
response,
|
|
||||||
403,
|
|
||||||
'当前流程不支持工程师接收,请由创建人直接指定接收工程师',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:接收接口对所有角色都不可用,未登录 401', async () => {
|
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/tasks/accept role matrix',
|
name: 'POST /b/tasks/accept role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
@ -292,7 +506,7 @@ describe('BTasksController (e2e)', () => {
|
|||||||
[Role.DIRECTOR]: 403,
|
[Role.DIRECTOR]: 403,
|
||||||
[Role.LEADER]: 403,
|
[Role.LEADER]: 403,
|
||||||
[Role.DOCTOR]: 403,
|
[Role.DOCTOR]: 403,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 404,
|
||||||
},
|
},
|
||||||
sendAsRole: async (_role, token) =>
|
sendAsRole: async (_role, token) =>
|
||||||
request(ctx.app.getHttpServer())
|
request(ctx.app.getHttpServer())
|
||||||
@ -308,58 +522,83 @@ describe('BTasksController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /b/tasks/complete', () => {
|
describe('POST /b/tasks/complete', () => {
|
||||||
it('成功:ENGINEER 可直接完成已指派任务并同步设备压力', async () => {
|
it('成功:ENGINEER 可完成自己已接收的任务并同步设备压力', async () => {
|
||||||
const targetPressure = '1.5';
|
const targetPressure = '1.5';
|
||||||
const task = await publishAssignedTask(
|
const [device] = await createAdjustableDevices();
|
||||||
ctx.fixtures.devices.deviceA1Id,
|
const task = await publishPendingTask(device.id, targetPressure);
|
||||||
targetPressure,
|
await acceptPendingTask(task.id);
|
||||||
);
|
const proof = await uploadEngineerProof();
|
||||||
|
|
||||||
const completeResponse = await request(ctx.app.getHttpServer())
|
const completeResponse = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/complete')
|
.post('/b/tasks/complete')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
.send({ taskId: task.id });
|
.send(buildCompletionPayload(task.id, proof));
|
||||||
|
|
||||||
expectSuccessEnvelope(completeResponse, 201);
|
expectSuccessEnvelope(completeResponse, 201);
|
||||||
expect(completeResponse.body.data.status).toBe(TaskStatus.COMPLETED);
|
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({
|
const updatedDevice = await ctx.prisma.device.findUnique({
|
||||||
where: { id: ctx.fixtures.devices.deviceA1Id },
|
where: { id: device.id },
|
||||||
select: { currentPressure: true },
|
select: { currentPressure: true },
|
||||||
});
|
});
|
||||||
expect(device?.currentPressure).toBe(targetPressure);
|
expect(updatedDevice?.currentPressure).toBe(targetPressure);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:完成不存在任务返回 404', async () => {
|
it('失败:完成不存在任务返回 404', async () => {
|
||||||
|
const proof = await uploadEngineerProof();
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/complete')
|
.post('/b/tasks/complete')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
.send({ taskId: 99999999 });
|
.send(buildCompletionPayload(99999999, proof));
|
||||||
|
|
||||||
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
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 () => {
|
it('状态机失败:重复完成返回 409', async () => {
|
||||||
const task = await publishAssignedTask(
|
const [device] = await createAdjustableDevices();
|
||||||
ctx.fixtures.devices.deviceA2Id,
|
const task = await publishPendingTask(device.id, '1');
|
||||||
'1',
|
await acceptPendingTask(task.id);
|
||||||
);
|
const proof = await uploadEngineerProof();
|
||||||
|
|
||||||
const firstComplete = await request(ctx.app.getHttpServer())
|
const firstComplete = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/complete')
|
.post('/b/tasks/complete')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
.send({ taskId: task.id });
|
.send(buildCompletionPayload(task.id, proof));
|
||||||
expectSuccessEnvelope(firstComplete, 201);
|
expectSuccessEnvelope(firstComplete, 201);
|
||||||
|
|
||||||
const secondComplete = await request(ctx.app.getHttpServer())
|
const secondComplete = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/complete')
|
.post('/b/tasks/complete')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
.send({ taskId: task.id });
|
.send(buildCompletionPayload(task.id, proof));
|
||||||
|
|
||||||
expectErrorEnvelope(secondComplete, 409, '仅已指派任务可执行完成');
|
expectErrorEnvelope(secondComplete, 409, '仅已接收任务可执行完成');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => {
|
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => {
|
||||||
|
const proof = await uploadEngineerProof();
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/tasks/complete role matrix',
|
name: 'POST /b/tasks/complete role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
@ -375,21 +614,19 @@ describe('BTasksController (e2e)', () => {
|
|||||||
request(ctx.app.getHttpServer())
|
request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/complete')
|
.post('/b/tasks/complete')
|
||||||
.set('Authorization', `Bearer ${token}`)
|
.set('Authorization', `Bearer ${token}`)
|
||||||
.send({ taskId: 99999999 }),
|
.send(buildCompletionPayload(99999999, proof)),
|
||||||
sendWithoutToken: async () =>
|
sendWithoutToken: async () =>
|
||||||
request(ctx.app.getHttpServer())
|
request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/complete')
|
.post('/b/tasks/complete')
|
||||||
.send({ taskId: 99999999 }),
|
.send(buildCompletionPayload(99999999, proof)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /b/tasks/cancel', () => {
|
describe('POST /b/tasks/cancel', () => {
|
||||||
it('成功:DOCTOR 可取消自己创建的已指派任务', async () => {
|
it('成功:DOCTOR 可取消自己创建的待接收任务', async () => {
|
||||||
const task = await publishAssignedTask(
|
const [device] = await createAdjustableDevices();
|
||||||
ctx.fixtures.devices.deviceA3Id,
|
const task = await publishPendingTask(device.id, '1.5');
|
||||||
'1.5',
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/cancel')
|
.post('/b/tasks/cancel')
|
||||||
@ -400,6 +637,29 @@ describe('BTasksController (e2e)', () => {
|
|||||||
expect(response.body.data.status).toBe(TaskStatus.CANCELLED);
|
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 () => {
|
it('失败:取消不存在任务返回 404', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/cancel')
|
.post('/b/tasks/cancel')
|
||||||
@ -410,15 +670,15 @@ describe('BTasksController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('状态机失败:已完成任务不可取消返回 409', async () => {
|
it('状态机失败:已完成任务不可取消返回 409', async () => {
|
||||||
const task = await publishAssignedTask(
|
const [device] = await createAdjustableDevices();
|
||||||
ctx.fixtures.devices.deviceA2Id,
|
const task = await publishPendingTask(device.id, '1.5');
|
||||||
'1.5',
|
await acceptPendingTask(task.id);
|
||||||
);
|
const proof = await uploadEngineerProof();
|
||||||
|
|
||||||
const completeResponse = await request(ctx.app.getHttpServer())
|
const completeResponse = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/complete')
|
.post('/b/tasks/complete')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||||
.send({ taskId: task.id });
|
.send(buildCompletionPayload(task.id, proof));
|
||||||
expectSuccessEnvelope(completeResponse, 201);
|
expectSuccessEnvelope(completeResponse, 201);
|
||||||
|
|
||||||
const cancelResponse = await request(ctx.app.getHttpServer())
|
const cancelResponse = await request(ctx.app.getHttpServer())
|
||||||
@ -426,7 +686,7 @@ describe('BTasksController (e2e)', () => {
|
|||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
.send({ taskId: task.id });
|
.send({ taskId: task.id });
|
||||||
|
|
||||||
expectErrorEnvelope(cancelResponse, 409, '仅待指派/已指派任务可取消');
|
expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||||||
@ -439,7 +699,7 @@ describe('BTasksController (e2e)', () => {
|
|||||||
[Role.DIRECTOR]: 404,
|
[Role.DIRECTOR]: 404,
|
||||||
[Role.LEADER]: 404,
|
[Role.LEADER]: 404,
|
||||||
[Role.DOCTOR]: 404,
|
[Role.DOCTOR]: 404,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 404,
|
||||||
},
|
},
|
||||||
sendAsRole: async (_role, token) =>
|
sendAsRole: async (_role, token) =>
|
||||||
request(ctx.app.getHttpServer())
|
request(ctx.app.getHttpServer())
|
||||||
|
|||||||
@ -97,7 +97,11 @@ describe('BUploadsController (e2e)', () => {
|
|||||||
contentType: 'image/png',
|
contentType: 'image/png',
|
||||||
});
|
});
|
||||||
|
|
||||||
expectErrorEnvelope(response, 400, '系统管理员上传文件时必须显式指定 hospitalId');
|
expectErrorEnvelope(
|
||||||
|
response,
|
||||||
|
400,
|
||||||
|
'系统管理员上传文件时必须显式指定 hospitalId',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:DIRECTOR 查询影像库返回 403', async () => {
|
it('失败:DIRECTOR 查询影像库返回 403', async () => {
|
||||||
@ -116,7 +120,7 @@ describe('BUploadsController (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色矩阵:上传允许系统/医院/主任/组长/医生,工程师 403,未登录 401', async () => {
|
it('角色矩阵:上传允许系统/医院/主任/组长/医生/工程师,未登录 401', async () => {
|
||||||
await assertRoleMatrix({
|
await assertRoleMatrix({
|
||||||
name: 'POST /b/uploads role matrix',
|
name: 'POST /b/uploads role matrix',
|
||||||
tokens: ctx.tokens,
|
tokens: ctx.tokens,
|
||||||
@ -126,7 +130,7 @@ describe('BUploadsController (e2e)', () => {
|
|||||||
[Role.DIRECTOR]: 201,
|
[Role.DIRECTOR]: 201,
|
||||||
[Role.LEADER]: 201,
|
[Role.LEADER]: 201,
|
||||||
[Role.DOCTOR]: 201,
|
[Role.DOCTOR]: 201,
|
||||||
[Role.ENGINEER]: 403,
|
[Role.ENGINEER]: 201,
|
||||||
},
|
},
|
||||||
sendAsRole: async (role, token) => {
|
sendAsRole: async (role, token) => {
|
||||||
const req = request(ctx.app.getHttpServer())
|
const req = request(ctx.app.getHttpServer())
|
||||||
@ -173,7 +177,8 @@ describe('BUploadsController (e2e)', () => {
|
|||||||
}
|
}
|
||||||
return req;
|
return req;
|
||||||
},
|
},
|
||||||
sendWithoutToken: async () => request(ctx.app.getHttpServer()).get('/b/uploads'),
|
sendWithoutToken: async () =>
|
||||||
|
request(ctx.app.getHttpServer()).get('/b/uploads'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,6 +12,10 @@ export const publishTask = (data) => {
|
|||||||
return request.post('/b/tasks/publish', data);
|
return request.post('/b/tasks/publish', data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const acceptTask = (data) => {
|
||||||
|
return request.post('/b/tasks/accept', data);
|
||||||
|
};
|
||||||
|
|
||||||
export const completeTask = (data) => {
|
export const completeTask = (data) => {
|
||||||
return request.post('/b/tasks/complete', data);
|
return request.post('/b/tasks/complete', data);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -53,9 +53,19 @@
|
|||||||
<el-table-column prop="modelCode" label="型号编码" min-width="180" />
|
<el-table-column prop="modelCode" label="型号编码" min-width="180" />
|
||||||
<el-table-column prop="manufacturer" label="厂家" min-width="180" />
|
<el-table-column prop="manufacturer" label="厂家" min-width="180" />
|
||||||
<el-table-column prop="name" label="名称" min-width="180" />
|
<el-table-column prop="name" label="名称" min-width="180" />
|
||||||
|
<el-table-column label="类型" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isValve === false ? 'info' : 'success'">
|
||||||
|
{{ row.isValve === false ? '管子' : '阀门' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="挡位" min-width="220">
|
<el-table-column label="挡位" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-if="row.pressureLevels?.length" class="pressure-tag-list">
|
<div
|
||||||
|
v-if="row.isValve !== false && row.pressureLevels?.length"
|
||||||
|
class="pressure-tag-list"
|
||||||
|
>
|
||||||
<el-tag
|
<el-tag
|
||||||
v-for="level in row.pressureLevels"
|
v-for="level in row.pressureLevels"
|
||||||
:key="`${row.id}-${level}`"
|
:key="`${row.id}-${level}`"
|
||||||
@ -127,9 +137,19 @@
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="阀门">
|
||||||
|
<el-switch
|
||||||
|
v-model="form.isValve"
|
||||||
|
active-text="是"
|
||||||
|
inactive-text="否"
|
||||||
|
@change="handleValveToggle"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-form-item label="压力挡位">
|
<el-form-item v-if="form.isValve" label="压力挡位">
|
||||||
<div class="pressure-level-panel">
|
<div class="pressure-level-panel">
|
||||||
<div
|
<div
|
||||||
v-for="(level, index) in form.pressureLevels"
|
v-for="(level, index) in form.pressureLevels"
|
||||||
@ -159,6 +179,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-alert
|
||||||
|
v-else
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="当前目录项为管子或附件,不需要维护压力挡位。"
|
||||||
|
/>
|
||||||
|
|
||||||
<el-form-item label="备注">
|
<el-form-item label="备注">
|
||||||
<el-input
|
<el-input
|
||||||
@ -223,6 +249,7 @@ function createDefaultForm() {
|
|||||||
modelCode: '',
|
modelCode: '',
|
||||||
manufacturer: '',
|
manufacturer: '',
|
||||||
name: '',
|
name: '',
|
||||||
|
isValve: true,
|
||||||
pressureLevels: [''],
|
pressureLevels: [''],
|
||||||
notes: '',
|
notes: '',
|
||||||
};
|
};
|
||||||
@ -281,9 +308,7 @@ async function fetchData() {
|
|||||||
const res = await getImplantCatalogs({
|
const res = await getImplantCatalogs({
|
||||||
keyword: searchForm.keyword || undefined,
|
keyword: searchForm.keyword || undefined,
|
||||||
});
|
});
|
||||||
tableData.value = (Array.isArray(res) ? res : []).filter(
|
tableData.value = Array.isArray(res) ? res : [];
|
||||||
(item) => item.isPressureAdjustable !== false,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -300,11 +325,23 @@ function resetForm() {
|
|||||||
form.modelCode = next.modelCode;
|
form.modelCode = next.modelCode;
|
||||||
form.manufacturer = next.manufacturer;
|
form.manufacturer = next.manufacturer;
|
||||||
form.name = next.name;
|
form.name = next.name;
|
||||||
|
form.isValve = next.isValve;
|
||||||
form.pressureLevels = next.pressureLevels;
|
form.pressureLevels = next.pressureLevels;
|
||||||
form.notes = next.notes;
|
form.notes = next.notes;
|
||||||
currentId.value = null;
|
currentId.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleValveToggle(value) {
|
||||||
|
if (!value) {
|
||||||
|
form.pressureLevels = [''];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(form.pressureLevels) || form.pressureLevels.length === 0) {
|
||||||
|
form.pressureLevels = [''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addPressureLevel() {
|
function addPressureLevel() {
|
||||||
form.pressureLevels.push('');
|
form.pressureLevels.push('');
|
||||||
}
|
}
|
||||||
@ -330,8 +367,11 @@ function openEditDialog(row) {
|
|||||||
form.modelCode = row.modelCode || '';
|
form.modelCode = row.modelCode || '';
|
||||||
form.manufacturer = row.manufacturer || '';
|
form.manufacturer = row.manufacturer || '';
|
||||||
form.name = row.name || '';
|
form.name = row.name || '';
|
||||||
|
form.isValve = row.isValve !== false;
|
||||||
form.pressureLevels =
|
form.pressureLevels =
|
||||||
Array.isArray(row.pressureLevels) && row.pressureLevels.length > 0
|
row.isValve !== false &&
|
||||||
|
Array.isArray(row.pressureLevels) &&
|
||||||
|
row.pressureLevels.length > 0
|
||||||
? [...row.pressureLevels]
|
? [...row.pressureLevels]
|
||||||
: [''];
|
: [''];
|
||||||
form.notes = row.notes || '';
|
form.notes = row.notes || '';
|
||||||
@ -350,11 +390,15 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedLevels = normalizePressureLevels(form.pressureLevels);
|
const normalizedLevels = normalizePressureLevels(form.pressureLevels);
|
||||||
if (normalizedLevels.length !== form.pressureLevels.filter((level) => String(level ?? '').trim()).length) {
|
if (
|
||||||
|
form.isValve &&
|
||||||
|
normalizedLevels.length !==
|
||||||
|
form.pressureLevels.filter((level) => String(level ?? '').trim()).length
|
||||||
|
) {
|
||||||
ElMessage.warning('挡位格式不合法,请输入数字或一位小数字符串');
|
ElMessage.warning('挡位格式不合法,请输入数字或一位小数字符串');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (normalizedLevels.length === 0) {
|
if (form.isValve && normalizedLevels.length === 0) {
|
||||||
ElMessage.warning('请至少录入一个挡位');
|
ElMessage.warning('请至少录入一个挡位');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -365,8 +409,8 @@ async function handleSubmit() {
|
|||||||
modelCode: form.modelCode,
|
modelCode: form.modelCode,
|
||||||
manufacturer: form.manufacturer,
|
manufacturer: form.manufacturer,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
isPressureAdjustable: true,
|
isValve: form.isValve,
|
||||||
pressureLevels: normalizedLevels,
|
pressureLevels: form.isValve ? normalizedLevels : [],
|
||||||
notes: form.notes || undefined,
|
notes: form.notes || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -323,7 +323,7 @@
|
|||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
class="context-alert"
|
class="context-alert"
|
||||||
title="这里只选择目标挡位;当前压力会在调压任务完成后自动刷新。"
|
title="这里只选择目标挡位;发布后由本院工程师接收,当前压力会在任务完成后自动刷新。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-descriptions :column="1" border size="small">
|
<el-descriptions :column="1" border size="small">
|
||||||
@ -358,20 +358,6 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="接收人">
|
|
||||||
<el-select
|
|
||||||
v-model="adjustForm.engineerId"
|
|
||||||
placeholder="请选择接收工程师"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="engineer in engineerOptions"
|
|
||||||
:key="engineer.id"
|
|
||||||
:label="formatEngineerLabel(engineer)"
|
|
||||||
:value="engineer.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="目标挡位">
|
<el-form-item label="目标挡位">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="adjustForm.targetPressure"
|
v-model="adjustForm.targetPressure"
|
||||||
@ -521,7 +507,9 @@
|
|||||||
>
|
>
|
||||||
<div class="material-preview-name">
|
<div class="material-preview-name">
|
||||||
{{
|
{{
|
||||||
material.name || material.type || `资料${index + 1}`
|
material.name ||
|
||||||
|
material.type ||
|
||||||
|
`资料${index + 1}`
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<el-image
|
<el-image
|
||||||
@ -574,6 +562,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="device-detail-tags">
|
<div class="device-detail-tags">
|
||||||
|
<el-tag
|
||||||
|
:type="
|
||||||
|
device.isValve === false ? 'info' : 'success'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ device.isValve === false ? '管子' : '阀门' }}
|
||||||
|
</el-tag>
|
||||||
<el-tag
|
<el-tag
|
||||||
v-if="device.isPressureAdjustable !== false"
|
v-if="device.isPressureAdjustable !== false"
|
||||||
type="success"
|
type="success"
|
||||||
@ -608,13 +603,25 @@
|
|||||||
{{ formatList(device.proximalPunctureAreas) }}
|
{{ formatList(device.proximalPunctureAreas) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="阀门植入部位">
|
<el-descriptions-item label="阀门植入部位">
|
||||||
{{ formatList(device.valvePlacementSites) }}
|
{{
|
||||||
|
device.isValve === false
|
||||||
|
? '-'
|
||||||
|
: formatList(device.valvePlacementSites)
|
||||||
|
}}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="初始压力">
|
<el-descriptions-item label="初始压力">
|
||||||
{{ formatValue(device.initialPressure) }}
|
{{
|
||||||
|
device.isValve === false
|
||||||
|
? '-'
|
||||||
|
: formatValue(device.initialPressure)
|
||||||
|
}}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="当前压力">
|
<el-descriptions-item label="当前压力">
|
||||||
{{ formatValue(device.currentPressure) }}
|
{{
|
||||||
|
device.isValve === false
|
||||||
|
? '-'
|
||||||
|
: formatValue(device.currentPressure)
|
||||||
|
}}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="植入物备注" :span="2">
|
<el-descriptions-item label="植入物备注" :span="2">
|
||||||
{{ device.implantNotes || '-' }}
|
{{ device.implantNotes || '-' }}
|
||||||
@ -639,42 +646,83 @@
|
|||||||
<el-empty v-else description="当前患者尚未录入手术信息" />
|
<el-empty v-else description="当前患者尚未录入手术信息" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="生命周期" name="lifecycle">
|
<el-tab-pane label="调压记录" name="adjustments">
|
||||||
<el-table
|
<div class="adjust-records-panel">
|
||||||
:data="detailLifecycle"
|
<div
|
||||||
border
|
v-if="detailAdjustDeviceOptions.length > 1"
|
||||||
stripe
|
class="adjust-records-toolbar"
|
||||||
max-height="520"
|
>
|
||||||
empty-text="暂无手术或调压事件"
|
<div class="adjust-records-toolbar-title">
|
||||||
>
|
当前患者有多台可调压设备,请切换设备分别查看调压记录
|
||||||
<el-table-column label="时间" width="180">
|
</div>
|
||||||
<template #default="{ row }">
|
<el-select
|
||||||
{{ formatDateTime(row.occurredAt) }}
|
v-model="detailAdjustDeviceId"
|
||||||
</template>
|
placeholder="请选择调压设备"
|
||||||
</el-table-column>
|
class="adjust-records-filter"
|
||||||
<el-table-column label="类型" width="110" align="center">
|
>
|
||||||
<template #default="{ row }">
|
<el-option
|
||||||
<el-tag :type="getLifecycleEventTagType(row.eventType)">
|
v-for="device in detailAdjustDeviceOptions"
|
||||||
{{ getLifecycleEventLabel(row.eventType) }}
|
:key="device.id"
|
||||||
</el-tag>
|
:label="device.label"
|
||||||
</template>
|
:value="device.id"
|
||||||
</el-table-column>
|
/>
|
||||||
<el-table-column label="事件说明" min-width="340">
|
</el-select>
|
||||||
<template #default="{ row }">
|
</div>
|
||||||
{{ formatLifecycleSummary(row) }}
|
|
||||||
</template>
|
<el-table
|
||||||
</el-table-column>
|
:data="detailAdjustRecords"
|
||||||
<el-table-column label="补充信息" min-width="240">
|
border
|
||||||
<template #default="{ row }">
|
stripe
|
||||||
{{ formatLifecycleMeta(row) }}
|
max-height="520"
|
||||||
</template>
|
empty-text="暂无调压记录"
|
||||||
</el-table-column>
|
>
|
||||||
<el-table-column label="医院" min-width="150">
|
<el-table-column label="时间" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.hospital?.name || '-' }}
|
{{ formatDateTime(row.occurredAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
<el-table-column label="调压设备" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>
|
||||||
|
{{
|
||||||
|
row.device?.implantName ||
|
||||||
|
row.device?.implantModel ||
|
||||||
|
'未命名设备'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="sub-text">
|
||||||
|
{{ row.device?.implantModel || '-' }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
label="压力变化"
|
||||||
|
min-width="160"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatAdjustRecordPressureChange(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="任务状态" width="120" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getTaskStatusTagType(row.task?.status)">
|
||||||
|
{{ getTaskStatusLabel(row.task?.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="关联手术" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.surgery?.surgeryName || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="医院" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.hospital?.name || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</template>
|
</template>
|
||||||
@ -712,7 +760,7 @@ import {
|
|||||||
} from '../../api/patients';
|
} from '../../api/patients';
|
||||||
import { getImplantCatalogs } from '../../api/devices';
|
import { getImplantCatalogs } from '../../api/devices';
|
||||||
import { getDictionaries } from '../../api/dictionaries';
|
import { getDictionaries } from '../../api/dictionaries';
|
||||||
import { getTaskEngineers, publishTask } from '../../api/tasks';
|
import { publishTask } from '../../api/tasks';
|
||||||
import {
|
import {
|
||||||
getDepartments,
|
getDepartments,
|
||||||
getGroups,
|
getGroups,
|
||||||
@ -724,10 +772,6 @@ import {
|
|||||||
} from '../../constants/medical-dictionaries';
|
} from '../../constants/medical-dictionaries';
|
||||||
import { useUserStore } from '../../store/user';
|
import { useUserStore } from '../../store/user';
|
||||||
import SurgeryFormSection from './components/SurgeryFormSection.vue';
|
import SurgeryFormSection from './components/SurgeryFormSection.vue';
|
||||||
import {
|
|
||||||
LIFECYCLE_EVENT_LABELS,
|
|
||||||
LIFECYCLE_EVENT_TAG_TYPES,
|
|
||||||
} from './patient-form-options';
|
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -768,7 +812,6 @@ const hospitals = ref([]);
|
|||||||
const departments = ref([]);
|
const departments = ref([]);
|
||||||
const groups = ref([]);
|
const groups = ref([]);
|
||||||
const doctorOptions = ref([]);
|
const doctorOptions = ref([]);
|
||||||
const engineerOptions = ref([]);
|
|
||||||
const implantCatalogOptions = ref([]);
|
const implantCatalogOptions = ref([]);
|
||||||
const medicalDictionaryOptions = ref(createEmptyMedicalDictionaryOptions());
|
const medicalDictionaryOptions = ref(createEmptyMedicalDictionaryOptions());
|
||||||
|
|
||||||
@ -794,6 +837,7 @@ const detailDialogVisible = ref(false);
|
|||||||
const detailTab = ref('profile');
|
const detailTab = ref('profile');
|
||||||
const detailPatient = ref(null);
|
const detailPatient = ref(null);
|
||||||
const detailLifecycle = ref([]);
|
const detailLifecycle = ref([]);
|
||||||
|
const detailAdjustDeviceId = ref(null);
|
||||||
|
|
||||||
const patientForm = reactive({
|
const patientForm = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
@ -808,7 +852,6 @@ const initialSurgeryForm = ref(createSurgeryForm());
|
|||||||
const appendSurgeryForm = ref(createSurgeryForm());
|
const appendSurgeryForm = ref(createSurgeryForm());
|
||||||
const adjustForm = reactive({
|
const adjustForm = reactive({
|
||||||
deviceId: null,
|
deviceId: null,
|
||||||
engineerId: null,
|
|
||||||
targetPressure: 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 = {
|
const patientRules = {
|
||||||
name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
|
name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
|
||||||
phone: [
|
phone: [
|
||||||
@ -981,13 +1062,17 @@ function validateSurgeryForm(form) {
|
|||||||
if (!normalizeStringArray(device.proximalPunctureAreas, 2).length) {
|
if (!normalizeStringArray(device.proximalPunctureAreas, 2).length) {
|
||||||
return `设备 ${index + 1} 请填写近端穿刺区域`;
|
return `设备 ${index + 1} 请填写近端穿刺区域`;
|
||||||
}
|
}
|
||||||
if (!normalizeStringArray(device.valvePlacementSites, 2).length) {
|
if (
|
||||||
|
catalog.isValve !== false &&
|
||||||
|
!normalizeStringArray(device.valvePlacementSites, 2).length
|
||||||
|
) {
|
||||||
return `设备 ${index + 1} 请填写阀门植入部位`;
|
return `设备 ${index + 1} 请填写阀门植入部位`;
|
||||||
}
|
}
|
||||||
if (!String(device.distalShuntDirection || '').trim()) {
|
if (!String(device.distalShuntDirection || '').trim()) {
|
||||||
return `设备 ${index + 1} 请选择远端分流方向`;
|
return `设备 ${index + 1} 请选择远端分流方向`;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
catalog.isValve !== false &&
|
||||||
catalog.isPressureAdjustable &&
|
catalog.isPressureAdjustable &&
|
||||||
resolveCatalogPressureLevels(device.implantCatalogId).length > 0
|
resolveCatalogPressureLevels(device.implantCatalogId).length > 0
|
||||||
) {
|
) {
|
||||||
@ -1041,13 +1126,15 @@ function buildSurgeryPayload(form) {
|
|||||||
device.proximalPunctureAreas,
|
device.proximalPunctureAreas,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
valvePlacementSites: normalizeStringArray(
|
valvePlacementSites: normalizeStringArray(device.valvePlacementSites, 2)
|
||||||
device.valvePlacementSites,
|
.length
|
||||||
2,
|
? normalizeStringArray(device.valvePlacementSites, 2)
|
||||||
),
|
: undefined,
|
||||||
distalShuntDirection: String(device.distalShuntDirection || '').trim(),
|
distalShuntDirection: String(device.distalShuntDirection || '').trim(),
|
||||||
initialPressure:
|
initialPressure:
|
||||||
normalizePressureLabel(device.initialPressure) || undefined,
|
resolveCatalog(device.implantCatalogId)?.isValve === false
|
||||||
|
? undefined
|
||||||
|
: normalizePressureLabel(device.initialPressure) || undefined,
|
||||||
implantNotes: normalizeOptionalString(device.implantNotes),
|
implantNotes: normalizeOptionalString(device.implantNotes),
|
||||||
labelImageUrl: normalizeOptionalString(device.labelImageUrl),
|
labelImageUrl: normalizeOptionalString(device.labelImageUrl),
|
||||||
};
|
};
|
||||||
@ -1167,12 +1254,6 @@ function formatAdjustDeviceLabel(device) {
|
|||||||
].join(' | ');
|
].join(' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEngineerLabel(engineer) {
|
|
||||||
return [engineer.name || '-', engineer.phone || '-']
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' | ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '-';
|
return '-';
|
||||||
@ -1194,35 +1275,17 @@ function formatValue(value) {
|
|||||||
return value == null || value === '' ? '-' : value;
|
return value == null || value === '' ? '-' : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLifecycleEventLabel(type) {
|
function getTaskStatusLabel(status) {
|
||||||
return LIFECYCLE_EVENT_LABELS[type] || type;
|
return TASK_STATUS_LABELS[status] || status || '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLifecycleEventTagType(type) {
|
function getTaskStatusTagType(status) {
|
||||||
return LIFECYCLE_EVENT_TAG_TYPES[type] || 'info';
|
return TASK_STATUS_TAG_TYPES[status] || 'info';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLifecycleSummary(event) {
|
function formatAdjustRecordPressureChange(event) {
|
||||||
if (event.eventType === 'SURGERY') {
|
return `${event.taskItem?.oldPressure ?? '-'} -> ${
|
||||||
return `${event.surgery?.surgeryName || '-'} | 主刀 ${
|
event.taskItem?.targetPressure ?? '-'
|
||||||
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 || '-'
|
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1312,9 +1375,7 @@ async function fetchOrgNodesForDoctorTree(
|
|||||||
|
|
||||||
async function fetchImplantCatalogOptions() {
|
async function fetchImplantCatalogOptions() {
|
||||||
const res = await getImplantCatalogs();
|
const res = await getImplantCatalogs();
|
||||||
implantCatalogOptions.value = (Array.isArray(res) ? res : []).filter(
|
implantCatalogOptions.value = Array.isArray(res) ? res : [];
|
||||||
(item) => item.isPressureAdjustable !== false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMedicalDictionaryOptions() {
|
async function fetchMedicalDictionaryOptions() {
|
||||||
@ -1322,16 +1383,6 @@ async function fetchMedicalDictionaryOptions() {
|
|||||||
medicalDictionaryOptions.value = groupMedicalDictionaryItems(res);
|
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() {
|
async function fetchData() {
|
||||||
if (isSystemAdmin.value && !searchForm.hospitalId) {
|
if (isSystemAdmin.value && !searchForm.hospitalId) {
|
||||||
allPatients.value = [];
|
allPatients.value = [];
|
||||||
@ -1599,11 +1650,45 @@ function canAdjustDevice(device) {
|
|||||||
return canPublishAdjustTask.value && isAdjustableDeviceAvailable(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() {
|
function resetAdjustDialog() {
|
||||||
currentAdjustPatient.value = null;
|
currentAdjustPatient.value = null;
|
||||||
engineerOptions.value = [];
|
|
||||||
adjustForm.deviceId = null;
|
adjustForm.deviceId = null;
|
||||||
adjustForm.engineerId = null;
|
|
||||||
adjustForm.targetPressure = null;
|
adjustForm.targetPressure = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1632,15 +1717,8 @@ async function openPatientAdjustDialog(row) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadAssignableEngineers(detail.hospital?.id || detail.hospitalId);
|
|
||||||
if (!engineerOptions.value.length) {
|
|
||||||
ElMessage.warning('当前医院下暂无可指派的工程师');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentAdjustPatient.value = detail;
|
currentAdjustPatient.value = detail;
|
||||||
adjustForm.deviceId = adjustableDevices[0].id;
|
adjustForm.deviceId = adjustableDevices[0].id;
|
||||||
adjustForm.engineerId = engineerOptions.value[0].id;
|
|
||||||
handleAdjustDeviceChange(adjustForm.deviceId);
|
handleAdjustDeviceChange(adjustForm.deviceId);
|
||||||
adjustDialogVisible.value = true;
|
adjustDialogVisible.value = true;
|
||||||
}
|
}
|
||||||
@ -1651,34 +1729,26 @@ async function openAdjustDialog(device) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadAssignableEngineers(patient.hospital?.id || patient.hospitalId);
|
|
||||||
if (!engineerOptions.value.length) {
|
|
||||||
ElMessage.warning('当前医院下暂无可指派的工程师');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentAdjustPatient.value = patient;
|
currentAdjustPatient.value = patient;
|
||||||
adjustForm.deviceId = device.id;
|
adjustForm.deviceId = device.id;
|
||||||
adjustForm.engineerId = engineerOptions.value[0].id;
|
|
||||||
handleAdjustDeviceChange(device.id);
|
handleAdjustDeviceChange(device.id);
|
||||||
adjustDialogVisible.value = true;
|
adjustDialogVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmitAdjustTask() {
|
async function handleSubmitAdjustTask() {
|
||||||
if (!currentAdjustDevice.value || !adjustForm.engineerId) {
|
if (!currentAdjustDevice.value) {
|
||||||
ElMessage.warning('请选择接收人和目标挡位');
|
ElMessage.warning('请选择调压设备和目标挡位');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adjustForm.targetPressure == null) {
|
if (adjustForm.targetPressure == null) {
|
||||||
ElMessage.warning('请选择接收人和目标挡位');
|
ElMessage.warning('请选择调压设备和目标挡位');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
adjustSubmitLoading.value = true;
|
adjustSubmitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await publishTask({
|
await publishTask({
|
||||||
engineerId: adjustForm.engineerId,
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId: adjustForm.deviceId,
|
deviceId: adjustForm.deviceId,
|
||||||
@ -1687,7 +1757,7 @@ async function handleSubmitAdjustTask() {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
ElMessage.success('调压任务已创建,完成后当前压力会自动刷新');
|
ElMessage.success('调压任务已发布,待本院工程师接收');
|
||||||
adjustDialogVisible.value = false;
|
adjustDialogVisible.value = false;
|
||||||
|
|
||||||
if (detailPatient.value?.id === currentAdjustPatient.value?.id) {
|
if (detailPatient.value?.id === currentAdjustPatient.value?.id) {
|
||||||
@ -1696,7 +1766,7 @@ async function handleSubmitAdjustTask() {
|
|||||||
phone: detailPatient.value.phone,
|
phone: detailPatient.value.phone,
|
||||||
idCard: detailPatient.value.idCard,
|
idCard: detailPatient.value.idCard,
|
||||||
});
|
});
|
||||||
detailTab.value = 'lifecycle';
|
detailTab.value = 'adjustments';
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
adjustSubmitLoading.value = false;
|
adjustSubmitLoading.value = false;
|
||||||
@ -1723,6 +1793,7 @@ async function openDetailDialog(row) {
|
|||||||
detailTab.value = 'profile';
|
detailTab.value = 'profile';
|
||||||
detailPatient.value = null;
|
detailPatient.value = null;
|
||||||
detailLifecycle.value = [];
|
detailLifecycle.value = [];
|
||||||
|
detailAdjustDeviceId.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const detailPromise = getPatientById(row.id);
|
const detailPromise = getPatientById(row.id);
|
||||||
@ -1746,6 +1817,11 @@ async function openDetailDialog(row) {
|
|||||||
detailLifecycle.value = fullLifecycle.filter(
|
detailLifecycle.value = fullLifecycle.filter(
|
||||||
(item) => item.patient?.id === detail.id,
|
(item) => item.patient?.id === detail.id,
|
||||||
);
|
);
|
||||||
|
const deviceOptions = buildDetailAdjustDeviceOptions(
|
||||||
|
detail,
|
||||||
|
detailLifecycle.value,
|
||||||
|
);
|
||||||
|
detailAdjustDeviceId.value = deviceOptions[0]?.id || null;
|
||||||
} finally {
|
} finally {
|
||||||
detailLoading.value = false;
|
detailLoading.value = false;
|
||||||
}
|
}
|
||||||
@ -1988,6 +2064,32 @@ onMounted(async () => {
|
|||||||
object-fit: contain;
|
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 {
|
.adjust-dialog-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@ -2000,7 +2102,8 @@ onMounted(async () => {
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.section-card-head,
|
.section-card-head,
|
||||||
.surgery-card-head,
|
.surgery-card-head,
|
||||||
.device-detail-head {
|
.device-detail-head,
|
||||||
|
.adjust-records-toolbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
@ -2008,5 +2111,9 @@ onMounted(async () => {
|
|||||||
.surgery-card-tags {
|
.surgery-card-tags {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.adjust-records-filter {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -273,13 +273,24 @@
|
|||||||
/>
|
/>
|
||||||
<el-tag
|
<el-tag
|
||||||
v-if="resolveCatalog(device.implantCatalogId)"
|
v-if="resolveCatalog(device.implantCatalogId)"
|
||||||
type="success"
|
:type="
|
||||||
|
resolveCatalog(device.implantCatalogId)?.isValve === false
|
||||||
|
? 'info'
|
||||||
|
: 'success'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
调压设备
|
{{
|
||||||
|
resolveCatalog(device.implantCatalogId)?.isValve === false
|
||||||
|
? '管子'
|
||||||
|
: '阀门'
|
||||||
|
}}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="resolvePressureLevels(device.implantCatalogId).length"
|
v-if="
|
||||||
|
resolveCatalog(device.implantCatalogId)?.isValve !== false &&
|
||||||
|
resolvePressureLevels(device.implantCatalogId).length
|
||||||
|
"
|
||||||
class="pressure-level-hint"
|
class="pressure-level-hint"
|
||||||
>
|
>
|
||||||
挡位:
|
挡位:
|
||||||
@ -342,7 +353,11 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :md="12">
|
<el-col
|
||||||
|
v-if="resolveCatalog(device.implantCatalogId)?.isValve === true"
|
||||||
|
:xs="24"
|
||||||
|
:md="12"
|
||||||
|
>
|
||||||
<el-form-item label="阀门植入部位">
|
<el-form-item label="阀门植入部位">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="device.valvePlacementSites"
|
v-model="device.valvePlacementSites"
|
||||||
@ -363,7 +378,11 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :md="12">
|
<el-col
|
||||||
|
v-if="resolveCatalog(device.implantCatalogId)?.isValve === true"
|
||||||
|
:xs="24"
|
||||||
|
:md="12"
|
||||||
|
>
|
||||||
<el-form-item label="初始压力">
|
<el-form-item label="初始压力">
|
||||||
<el-select
|
<el-select
|
||||||
v-if="resolvePressureLevels(device.implantCatalogId).length"
|
v-if="resolvePressureLevels(device.implantCatalogId).length"
|
||||||
@ -381,15 +400,6 @@
|
|||||||
:value="level"
|
:value="level"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input
|
|
||||||
v-else
|
|
||||||
v-model="device.initialPressure"
|
|
||||||
:disabled="
|
|
||||||
!resolveCatalog(device.implantCatalogId)?.isPressureAdjustable
|
|
||||||
"
|
|
||||||
placeholder="可为空"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
<div class="field-hint">
|
<div class="field-hint">
|
||||||
当前压力创建后默认继承初始压力,后续以调压任务完成结果为准
|
当前压力创建后默认继承初始压力,后续以调压任务完成结果为准
|
||||||
</div>
|
</div>
|
||||||
@ -554,6 +564,12 @@ const formatCatalogLabel = (catalog) => {
|
|||||||
|
|
||||||
const handleCatalogChange = (device) => {
|
const handleCatalogChange = (device) => {
|
||||||
const catalog = resolveCatalog(device.implantCatalogId);
|
const catalog = resolveCatalog(device.implantCatalogId);
|
||||||
|
if (catalog?.isValve === false) {
|
||||||
|
device.valvePlacementSites = [];
|
||||||
|
device.initialPressure = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!catalog?.isPressureAdjustable) {
|
if (!catalog?.isPressureAdjustable) {
|
||||||
device.initialPressure = '';
|
device.initialPressure = '';
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -54,7 +54,7 @@
|
|||||||
<el-alert
|
<el-alert
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
title="发布调压任务请到患者页面选择患者并指定接收人后发起;本页仅用于查看调压记录。"
|
:title="pageAlertTitle"
|
||||||
class="page-alert"
|
class="page-alert"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -140,6 +140,92 @@
|
|||||||
{{ formatDateTime(row.createdAt) }}
|
{{ formatDateTime(row.createdAt) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="完成凭证" min-width="260">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
Array.isArray(row.completionMaterials) &&
|
||||||
|
row.completionMaterials.length > 0
|
||||||
|
"
|
||||||
|
class="completion-preview-list"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(material, index) in row.completionMaterials"
|
||||||
|
:key="material.assetId || material.url || index"
|
||||||
|
class="completion-preview-card"
|
||||||
|
>
|
||||||
|
<div class="completion-preview-name">
|
||||||
|
{{ material.name || material.type || '完成凭证' }}
|
||||||
|
</div>
|
||||||
|
<el-image
|
||||||
|
v-if="isImageMaterial(material)"
|
||||||
|
:src="material.url"
|
||||||
|
:preview-src-list="[material.url]"
|
||||||
|
preview-teleported
|
||||||
|
fit="contain"
|
||||||
|
class="completion-preview-image"
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-else-if="isVideoMaterial(material)"
|
||||||
|
:src="material.url"
|
||||||
|
class="completion-preview-video"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
v-else-if="material.url"
|
||||||
|
:href="material.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="completion-preview-link"
|
||||||
|
>
|
||||||
|
查看文件
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-if="isEngineer"
|
||||||
|
label="操作"
|
||||||
|
width="220"
|
||||||
|
fixed="right"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="canAccept(row)"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="actionTaskId === row.taskId && actionType === 'accept'"
|
||||||
|
@click="handleAccept(row)"
|
||||||
|
>
|
||||||
|
接收
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canCancel(row)"
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:loading="actionTaskId === row.taskId && actionType === 'cancel'"
|
||||||
|
@click="handleCancel(row)"
|
||||||
|
>
|
||||||
|
取消接收
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canComplete(row)"
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
@click="openCompleteDialog(row)"
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</el-button>
|
||||||
|
<span v-if="!canAccept(row) && !canCancel(row) && !canComplete(row)"
|
||||||
|
>-</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<div class="pagination-container">
|
<div class="pagination-container">
|
||||||
@ -155,29 +241,148 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="completeDialogVisible"
|
||||||
|
title="完成调压任务"
|
||||||
|
width="720px"
|
||||||
|
destroy-on-close
|
||||||
|
@closed="resetCompleteDialog"
|
||||||
|
>
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="完成前请先上传至少一张图片或一个视频凭证,完成后系统才会更新当前压力。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="completeDialogRow" class="complete-summary-card">
|
||||||
|
<div class="complete-summary-line">
|
||||||
|
<span class="summary-label">患者</span>
|
||||||
|
<span>{{ completeDialogRow.patient?.name || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="complete-summary-line">
|
||||||
|
<span class="summary-label">设备</span>
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
completeDialogRow.device?.implantName ||
|
||||||
|
completeDialogRow.device?.implantModel ||
|
||||||
|
'-'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="complete-summary-line">
|
||||||
|
<span class="summary-label">目标挡位</span>
|
||||||
|
<span>{{ formatPressureChange(completeDialogRow) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="complete-upload-actions">
|
||||||
|
<AssetUploadButton
|
||||||
|
button-text="上传图片或视频"
|
||||||
|
button-type="primary"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
:hospital-id="uploadHospitalId"
|
||||||
|
@uploaded="handleCompletionUploaded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="completionMaterials.length === 0" class="empty-hint">
|
||||||
|
请先上传完成凭证
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="completion-preview-list dialog-preview-list">
|
||||||
|
<div
|
||||||
|
v-for="(material, index) in completionMaterials"
|
||||||
|
:key="material.assetId || material.url || index"
|
||||||
|
class="completion-preview-card dialog-preview-card"
|
||||||
|
>
|
||||||
|
<div class="completion-preview-name">
|
||||||
|
{{ material.name || material.type || '完成凭证' }}
|
||||||
|
</div>
|
||||||
|
<el-image
|
||||||
|
v-if="isImageMaterial(material)"
|
||||||
|
:src="material.url"
|
||||||
|
:preview-src-list="[material.url]"
|
||||||
|
preview-teleported
|
||||||
|
fit="contain"
|
||||||
|
class="completion-preview-image"
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-else-if="isVideoMaterial(material)"
|
||||||
|
:src="material.url"
|
||||||
|
class="completion-preview-video"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
v-else-if="material.url"
|
||||||
|
:href="material.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="completion-preview-link"
|
||||||
|
>
|
||||||
|
查看文件
|
||||||
|
</a>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
@click="removeCompletionMaterial(index)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="completeDialogVisible = false">关闭</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="completeSubmitting"
|
||||||
|
@click="submitComplete"
|
||||||
|
>
|
||||||
|
确认完成
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { getTasks } from '../../api/tasks';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import AssetUploadButton from '../../components/AssetUploadButton.vue';
|
||||||
|
import {
|
||||||
|
acceptTask,
|
||||||
|
cancelTask,
|
||||||
|
completeTask,
|
||||||
|
getTasks,
|
||||||
|
} from '../../api/tasks';
|
||||||
import { getHospitals } from '../../api/organization';
|
import { getHospitals } from '../../api/organization';
|
||||||
import { useUserStore } from '../../store/user';
|
import { useUserStore } from '../../store/user';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
|
const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
|
||||||
|
const isEngineer = computed(() => userStore.role === 'ENGINEER');
|
||||||
|
const currentUserId = computed(() => userStore.userInfo?.id || null);
|
||||||
|
const uploadHospitalId = computed(() => userStore.userInfo?.hospitalId || null);
|
||||||
|
const pageAlertTitle = computed(() =>
|
||||||
|
isEngineer.value
|
||||||
|
? '待接收任务可在本页直接接收;已接收任务可取消接收或上传图片/视频后完成。'
|
||||||
|
: '调压任务请到患者页面发布;工程师在本页接收、取消和完成,本页同时用于查看调压记录。',
|
||||||
|
);
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ label: '待指派', value: 'PENDING' },
|
{ label: '待接收', value: 'PENDING' },
|
||||||
{ label: '已指派', value: 'ACCEPTED' },
|
{ label: '已接收', value: 'ACCEPTED' },
|
||||||
{ label: '已完成', value: 'COMPLETED' },
|
{ label: '已完成', value: 'COMPLETED' },
|
||||||
{ label: '已取消', value: 'CANCELLED' },
|
{ label: '已取消', value: 'CANCELLED' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusTextMap = {
|
const statusTextMap = {
|
||||||
PENDING: '待指派',
|
PENDING: '待接收',
|
||||||
ACCEPTED: '已指派',
|
ACCEPTED: '已接收',
|
||||||
COMPLETED: '已完成',
|
COMPLETED: '已完成',
|
||||||
CANCELLED: '已取消',
|
CANCELLED: '已取消',
|
||||||
};
|
};
|
||||||
@ -190,11 +395,17 @@ const statusTagTypeMap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const actionTaskId = ref(null);
|
||||||
|
const actionType = ref('');
|
||||||
const hospitals = ref([]);
|
const hospitals = ref([]);
|
||||||
const tableData = ref([]);
|
const tableData = ref([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(20);
|
const pageSize = ref(20);
|
||||||
|
const completeDialogVisible = ref(false);
|
||||||
|
const completeSubmitting = ref(false);
|
||||||
|
const completeDialogRow = ref(null);
|
||||||
|
const completionMaterials = ref([]);
|
||||||
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
hospitalId: null,
|
hospitalId: null,
|
||||||
@ -223,6 +434,43 @@ function formatPressureChange(row) {
|
|||||||
return `${formatValue(row.oldPressure)} -> ${formatValue(row.targetPressure)}`;
|
return `${formatValue(row.oldPressure)} -> ${formatValue(row.targetPressure)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canAccept(row) {
|
||||||
|
return isEngineer.value && row?.status === 'PENDING';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canComplete(row) {
|
||||||
|
return (
|
||||||
|
isEngineer.value &&
|
||||||
|
row?.status === 'ACCEPTED' &&
|
||||||
|
row?.engineer?.id === currentUserId.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCancel(row) {
|
||||||
|
return (
|
||||||
|
isEngineer.value &&
|
||||||
|
row?.status === 'ACCEPTED' &&
|
||||||
|
row?.engineer?.id === currentUserId.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageMaterial(material) {
|
||||||
|
return material?.type === 'IMAGE';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoMaterial(material) {
|
||||||
|
return material?.type === 'VIDEO';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCompletionMaterial(asset) {
|
||||||
|
return {
|
||||||
|
assetId: asset.id,
|
||||||
|
type: asset.type,
|
||||||
|
url: asset.url,
|
||||||
|
name: asset.originalName || asset.fileName || asset.name || '完成凭证',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchHospitalsForAdmin() {
|
async function fetchHospitalsForAdmin() {
|
||||||
if (!isSystemAdmin.value) {
|
if (!isSystemAdmin.value) {
|
||||||
return;
|
return;
|
||||||
@ -277,6 +525,113 @@ async function handlePageChange() {
|
|||||||
await fetchData();
|
await fetchData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAccept(row) {
|
||||||
|
actionTaskId.value = row.taskId;
|
||||||
|
actionType.value = 'accept';
|
||||||
|
try {
|
||||||
|
await acceptTask({ taskId: row.taskId });
|
||||||
|
ElMessage.success('任务已接收');
|
||||||
|
await fetchData();
|
||||||
|
} finally {
|
||||||
|
actionTaskId.value = null;
|
||||||
|
actionType.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCompleteDialog(row) {
|
||||||
|
completeDialogRow.value = row;
|
||||||
|
completionMaterials.value = [];
|
||||||
|
completeDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCompleteDialog() {
|
||||||
|
completeDialogRow.value = null;
|
||||||
|
completionMaterials.value = [];
|
||||||
|
completeSubmitting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCompletionUploaded(asset) {
|
||||||
|
if (!isImageMaterial(asset) && !isVideoMaterial(asset)) {
|
||||||
|
ElMessage.error('完成任务仅支持图片或视频凭证');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = normalizeCompletionMaterial(asset);
|
||||||
|
const existed = completionMaterials.value.some(
|
||||||
|
(item) => item.assetId === material.assetId,
|
||||||
|
);
|
||||||
|
if (existed) {
|
||||||
|
ElMessage.warning('该凭证已添加');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
completionMaterials.value = [...completionMaterials.value, material];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCompletionMaterial(index) {
|
||||||
|
completionMaterials.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'取消接收后,任务会退回待接收状态,其他同院工程师可重新接收,是否继续?',
|
||||||
|
'取消接收',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认取消接收',
|
||||||
|
cancelButtonText: '返回',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionTaskId.value = row.taskId;
|
||||||
|
actionType.value = 'cancel';
|
||||||
|
try {
|
||||||
|
await cancelTask({ taskId: row.taskId });
|
||||||
|
ElMessage.success('任务已退回待接收');
|
||||||
|
await fetchData();
|
||||||
|
} finally {
|
||||||
|
actionTaskId.value = null;
|
||||||
|
actionType.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitComplete() {
|
||||||
|
if (!completeDialogRow.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (completionMaterials.value.length === 0) {
|
||||||
|
ElMessage.error('请先上传至少一张图片或一个视频');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionTaskId.value = completeDialogRow.value.taskId;
|
||||||
|
actionType.value = 'complete';
|
||||||
|
completeSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await completeTask({
|
||||||
|
taskId: completeDialogRow.value.taskId,
|
||||||
|
completionMaterials: completionMaterials.value.map((item) => ({
|
||||||
|
assetId: item.assetId,
|
||||||
|
type: item.type,
|
||||||
|
url: item.url,
|
||||||
|
name: item.name,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
ElMessage.success('任务已完成');
|
||||||
|
completeDialogVisible.value = false;
|
||||||
|
resetCompleteDialog();
|
||||||
|
await fetchData();
|
||||||
|
} finally {
|
||||||
|
actionTaskId.value = null;
|
||||||
|
actionType.value = '';
|
||||||
|
completeSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchHospitalsForAdmin();
|
await fetchHospitalsForAdmin();
|
||||||
await fetchData();
|
await fetchData();
|
||||||
@ -312,6 +667,88 @@ onMounted(async () => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
padding: 24px 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-summary-card {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-summary-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-summary-line + .complete-summary-line {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
width: 72px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-upload-actions {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-preview-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-preview-card {
|
||||||
|
width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-preview-list {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-preview-card {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-preview-name {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-preview-image,
|
||||||
|
.completion-preview-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-preview-image {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-preview-image :deep(.el-image__inner) {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-preview-video {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-preview-link {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.pagination-container {
|
.pagination-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user