feat(签署任务): 新增「待科室确认」节点整条链路

原型 V10.2 的 needConfirm:检验 / 基因检测 / 自费类文书要求患者签完之后
由科室再复核签章,避免漏签补签。此前仓库完全没有这个节点,现在补全。

状态模型
- SigningTaskStatus 增加 confirming(待科室确认),BackendSigningTaskStatus 增加
  WAITING_CONFIRM;文档侧 DocumentRecord / TemplateResponseDto / SigningTemplate /
  CreateSigningTaskInput 增加 needConfirm。
- normalizeTaskStatus、mapSigningTask、mapDocument、toBackendTaskQuery 的 statusMap、
  mapAvailableTemplate、readLocalSigningTemplates 同步;needConfirm 取不到一律按 false,
  不凭空给任务多插一道人工节点。

流转
- completeSigningTask 按 needConfirm 决定落 confirming 还是 signed;
  新增 confirmDepartmentSigning(科室复核签章 → 已签署)。
  真实模式暂未接入(后端要重做),如实提示"待接入",不前端自己改状态。
- 任务事件与记录页事件补「已推送至 X 确认队列」「科室复核签章完成,文档归档」两条留痕。

工作台
- 列表徽标走 .task-status--confirming(原型 tg-cfm 橙),状态筛选加「待科室确认」。
- 详情动作按原型给:🏛 科室复核签章(确认完成)+ 催办 + 在线预览 + 下载已签 PDF;
  该状态下不给「作废」(原型只在待签署/已超时给)。
- 新增「科室确认节点」卡(待患者签署后 / 待科室确认 / ✓ 已确认三分支)——
  原型放在文书签名区那一格,这里做成紧跟文书的独立卡,真实模板与回退纸样都能显示。
- confirming 时文书只读(患者已签完,不该再改填写项)。
- 签署完成提示语按 needConfirm 分流:需科室确认的说「已流转至科室确认节点」,
  否则说「签署完成,文档已归档」—— 一律说"完成"会让人以为可以关页面了。
- 新增权限码 sign:task:confirm / sign:task:remind。

记录查询与报表
- 记录查询状态筛选与演示数据加该状态(2 条样本)。
- 报表 STATUS_LABELS / STATUS_COLUMNS / ReportStatusCounts / ReportGroupRow 加这一栏,
  单独成列不并进「已签署」——并进去会让已签署虚高,科室复核本来就是这类文书的卡点。
- 时效样本按「有签署用时」取,待科室确认同样计入;总览统计块加一块。

演示数据
- 工作台补 task-009(confirming + needConfirm),否则该状态的徽标、动作、
  筛选、报表栏在演示环境里一次都走不到。
- 注意:演示文库的麻精同意书**没有** needConfirm(原型里也确实没有),
  所以「新建任务 → 签署 → 落待科室确认」这条流转靠探针夹具验证,没有改产品种子。

验证:probe-dept-confirm.cjs(新建)36/36 PASS,含真实落笔签署后落待科室确认;
回归 probe-new-task 104/104、probe-method-domain 14/14、probe-workbench-lib 42/42、
probe-report-analytics 92/92(断言按七状态更新);vue-tsc 与 eslint 0 错。
This commit is contained in:
yelan
2026-09-26 20:09:00 +08:00
parent 82acc03442
commit 8c70f01e2e
25 changed files with 516 additions and 38 deletions
@@ -114,6 +114,9 @@ function mapDocument(dto: TemplateResponseDto): DocumentRecord {
// 现在取不到就是 undefined,卡片不渲染对应标签,不伪造取值
examType: dto.examType ?? undefined,
projectCodes: dto.projectCodes ?? undefined,
// 需科室确认:后端没给就是 false(走原路直接归档),
// 不能默认成 true —— 那会给所有文书凭空多插一道人工节点
needConfirm: dto.needConfirm ?? false,
}
}
+18 -1
View File
@@ -49,6 +49,13 @@ export interface DocumentRecord {
* 同样只在后端提供时才有值,缺省即不显示。
*/
projectCodes?: string[]
/**
* 患者签署后是否需要「科室复核签章」节点(原型 V10.2 的 needConfirm)。
*
* 检验 / 基因检测 / 自费类文书要科室再确认一次,避免漏签补签;
* 发起签署时它随文书带到任务上,决定签完落「待科室确认」还是「已签署」。
*/
needConfirm?: boolean
contentHtml?: string
contentSha256?: string
/** 当前已发布版本;发起签署必须使用它,不能误用最新草稿版本。 */
@@ -83,6 +90,8 @@ export interface TemplateResponseDto {
*/
examType?: string | null
projectCodes?: string[] | null
/** 患者签署后是否需要科室复核签章;取不到即按 false(直接归档)。 */
needConfirm?: boolean | null
status?: BackendTemplateStatus | null
currentVersionId?: string | null
latestVersionId?: string | null
@@ -331,7 +340,15 @@ export interface OrganizationResponseDto {
}
export type ReportBackendTaskStatus =
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED'
| 'CREATED'
| 'WAITING_SIGN'
| 'SIGNED'
| 'EXPIRED'
| 'VOIDED'
| 'GENERATING'
| 'FAILED'
/** 患者已签、等科室复核签章(与 workbench 的 WAITING_CONFIRM 同口径) */
| 'WAITING_CONFIRM'
/** 与 workbench 的 BackendSigningMethod 同口径:四种方式平铺,无通道归并 */
export type ReportBackendSignMethod = 'PAD' | 'SMS' | 'IPAD' | 'WX'
+31 -2
View File
@@ -122,6 +122,8 @@ interface MockRecordSeed {
minutes?: number
source?: string
voidReason?: string
/** 患者签署后是否需要科室复核签章(原型 needConfirm) */
needConfirm?: boolean
}
const MOCK_RECORD_SEEDS: MockRecordSeed[] = [
@@ -131,6 +133,8 @@ const MOCK_RECORD_SEEDS: MockRecordSeed[] = [
{ patient: '周雅琴', sex: '女', age: 29, visitType: '门诊', doc: 'mock-doc-endoscopy', status: 'pending', method: 'sms', days: 0, at: '11:20' },
{ patient: '赵某某', sex: '男', age: 52, visitType: '住院', doc: 'mock-doc-admission', status: 'pending', method: 'ipad', days: 0, at: '09:05', source: '工作台发起' },
{ patient: '孙浩然', sex: '男', age: 54, visitType: '住院', doc: 'mock-doc-endoscopy', status: 'pending', method: 'wx', days: 0, at: '08:50' },
// 「待科室确认」样本:患者已签完,等科室复核签章(原型 T0818006 那种)
{ patient: '王秀兰', sex: '女', age: 63, visitType: '住院', doc: 'mock-doc-endoscopy', status: 'confirming', method: 'sms', days: 0, at: '10:40', minutes: 5, needConfirm: true },
// 昨天
{ patient: '孙浩然', sex: '男', age: 54, visitType: '住院', doc: 'mock-doc-narcotic', status: 'signed', method: 'pad', days: 1, at: '08:40', minutes: 6, source: '工作台发起' },
{ patient: '王建国', sex: '男', age: 71, visitType: '住院', doc: 'mock-doc-blood', status: 'signed', method: 'sms', days: 1, at: '14:15', minutes: 9 },
@@ -139,6 +143,7 @@ const MOCK_RECORD_SEEDS: MockRecordSeed[] = [
// 近 3 天
{ patient: '吴桂芳', sex: '女', age: 73, visitType: '住院', doc: 'mock-doc-surgery', status: 'signed', method: 'pad', days: 2, at: '09:35', minutes: 5 },
{ patient: '刘伟', sex: '男', age: 45, visitType: '门诊', doc: 'mock-doc-cardiac', status: 'signed', method: 'sms', days: 2, at: '15:48', minutes: 3, source: '工作台发起' },
{ patient: '张海涛', sex: '男', age: 47, visitType: '门诊', doc: 'mock-doc-blood', status: 'confirming', method: 'pad', days: 2, at: '16:20', minutes: 6, needConfirm: true },
{ patient: '郑海燕', sex: '女', age: 38, visitType: '门诊', doc: 'mock-doc-endoscopy', status: 'expired', method: 'sms', days: 3, at: '10:22' },
{ patient: '黄志明', sex: '男', age: 66, visitType: '住院', doc: 'mock-doc-mri', status: 'signed', method: 'pad', days: 3, at: '13:07', minutes: 8 },
// 近 7 天
@@ -187,7 +192,9 @@ function buildMockRecord(seed: MockRecordSeed, index: number): SigningRecord {
const createdAt = formatDateTime(createdAtDate)
const signedDate = new Date(createdAtDate)
signedDate.setMinutes(signedDate.getMinutes() + (seed.minutes ?? 0))
const signedAt = seed.status === 'signed' ? formatDateTime(signedDate) : undefined
// 待科室确认的记录同样已经签过了,签名时间与签名图都要有
const hasSigned = seed.status === 'signed' || seed.status === 'confirming'
const signedAt = hasSigned ? formatDateTime(signedDate) : undefined
// 已超时的记录按 24 小时失效,其余按 48 小时;失效时间只做展示,不参与筛选
const expireHours = seed.status === 'expired' ? 24 : 48
@@ -218,6 +225,7 @@ function buildMockRecord(seed: MockRecordSeed, index: number): SigningRecord {
signerName: seed.patient,
method: seed.method,
status: seed.status,
needConfirm: seed.needConfirm ?? false,
source: seed.source ?? '电子病历',
createdAt,
updatedAt: signedAt ?? createdAt,
@@ -225,7 +233,7 @@ function buildMockRecord(seed: MockRecordSeed, index: number): SigningRecord {
expiresAt: formatDateTime(expiresAtDate),
voidReason: seed.voidReason,
// 已签署的记录带上签名图,否则「列表说已签署、打开没有签名」自相矛盾
signatureDataUrl: seed.status === 'signed' ? DEMO_SIGNATURE_DATA_URL : undefined,
signatureDataUrl: hasSigned ? DEMO_SIGNATURE_DATA_URL : undefined,
documentContentHtml: getMockDocumentHtml(document.id),
}
}
@@ -509,6 +517,27 @@ function buildMockEvents(record: SigningRecord): SigningTaskEvent[] {
})
}
// 需科室确认的记录:签完先入科室确认队列,科室签章后才归档(与工作台留痕同一口径)
if (record.needConfirm && record.signedAt) {
events.push({
id: `${record.id}-confirm-queued`,
time: record.signedAt,
text: `已推送至${record.department}确认队列,等待科室复核签章`,
eventType: 'CONFIRM_QUEUED',
channel: backendChannel,
})
if (record.status === 'signed') {
events.push({
id: `${record.id}-confirmed`,
time: record.updatedAt,
text: '科室复核签章完成,文档归档至本次诊疗记录',
eventType: 'CONFIRMED',
channel: backendChannel,
})
}
}
if (record.status === 'failed') {
events.push({
id: `${record.id}-failed`,
@@ -156,9 +156,10 @@ const DEMO_PATIENTS: DemoPatient[] = [
演示任务生成
========================================================================= */
/** 状态分布:已签署占多数,其余各留一点,六种状态在总览统计块里都看得到 */
/** 状态分布:已签署占多数,其余各留一点,七种状态在总览统计块里都看得到 */
const STATUS_WEIGHTS: { status: SigningTaskStatus; weight: number }[] = [
{ status: 'signed', weight: 68 },
{ status: 'signed', weight: 62 },
{ status: 'confirming', weight: 6 },
{ status: 'pending', weight: 9 },
{ status: 'signing', weight: 5 },
{ status: 'expired', weight: 8 },
@@ -259,7 +260,9 @@ function buildTasks(): ReportTaskRow[] {
createdAt.setDate(createdAt.getDate() - daysAgo)
createdAt.setHours(intBetween(random, 8, 19), intBetween(random, 0, 59), 0, 0)
const isSigned = status === 'signed'
// 待科室确认的任务患者也已经签完了,签署时间与用时都要有 ——
// 否则「签署时效分析」会把它们当成"还没签",24 小时内完成率被算低
const isSigned = status === 'signed' || status === 'confirming'
const signMinutes = isSigned ? buildSignMinutes(random) : undefined
const row: ReportTaskRow = {
+93 -2
View File
@@ -336,6 +336,35 @@ const defaultMockRecords: SigningTaskRecord[] = [
expiresAt: '2026-08-31 18:00',
updatedAt: '2026-08-27 09:50',
},
{
// 「待科室确认」节点:患者已经签完,只差科室复核签章。
// 四种状态徽标之外还必须有一条这个状态的样本,否则节点卡、复核签章动作、
// 状态筛选在演示环境里一次都走不到。
id: 'task-009',
templateVersionId: 'tpl-004-v1',
campus: '西院区',
patientId: 'P202610005',
patientName: '陈志强',
sex: '男',
age: 61,
visitNo: 'ZY20260826011',
visitType: '住院',
visitDate: '2026-08-26',
documentId: 'tpl-004',
documentName: '内镜检查知情同意书',
department: '普外科',
status: 'confirming',
signerName: '陈志强',
signerType: '本人',
// 需科室确认:检验 / 基因检测 / 自费类文书的典型场景,由模板标记带出
needConfirm: true,
method: 'sms',
smsMobileMasked: '135****9012',
source: '电子病历',
expiresAt: '2026-08-31 12:00',
signedAt: '2026-08-27 10:05',
updatedAt: '2026-08-27 10:05',
},
]
/**
@@ -364,7 +393,9 @@ function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[]
return records.map((record, index) => {
let next = record
if (next.status === 'signed' && !next.signatureDataUrl) {
// 待科室确认的任务患者已经签过了,同样要有签名图 ——
// 否则详情里文书签名位是空的,看着像"患者没签却进了科室确认队列"
if ((next.status === 'signed' || next.status === 'confirming') && !next.signatureDataUrl) {
next = { ...next, signatureDataUrl: DEMO_SIGNATURE_DATA_URL }
}
@@ -573,6 +604,8 @@ function normalizeTaskStatus(value: BackendSigningTaskStatus): SigningTaskStatus
switch (value) {
case 'SIGNED':
return 'signed'
case 'WAITING_CONFIRM':
return 'confirming'
case 'EXPIRED':
return 'expired'
case 'VOIDED':
@@ -631,6 +664,8 @@ function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): Signing
description: `已发布版本 ${dto.versionNo}`,
// 可用模板接口不返回方式 ACL;四种方式都放行,实际允许范围由后端在创建时校验。
supportedMethods: [...ALL_SIGNING_METHODS],
// 需科室确认随模板带出;后端没给就是 false(签完直接归档)
needConfirm: dto.needConfirm ?? false,
// 接口不返回正文,只能带出哈希;正文由 hydrateTemplateContents 按需补回
contentSha256: dto.contentSha256,
contentHtml: cachedContent || undefined,
@@ -726,6 +761,8 @@ function mapSigningTask(
// 签署人类型 / 关系:后端补齐后自动带出,取不到即 undefined,列表不显示代签标签
signerType: dto.signerType ?? undefined,
signerRelation: dto.signerRelation ?? undefined,
// 需科室确认:后端没给就是 false,不凭空给任务多插一道人工节点
needConfirm: dto.needConfirm ?? false,
method: normalizeSigningMethod(dto.signMethod),
smsMobileMasked: dto.smsMobileMasked ?? undefined,
status: normalizeTaskStatus(dto.status),
@@ -780,6 +817,7 @@ function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate
category: '—',
description: '',
supportedMethods: [...ALL_SIGNING_METHODS],
needConfirm: input.needConfirm ?? false,
}
}
@@ -797,6 +835,7 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
const statusMap: Partial<Record<SigningTaskStatus, BackendSigningTaskStatus>> = {
pending: 'WAITING_SIGN',
signing: 'GENERATING',
confirming: 'WAITING_CONFIRM',
signed: 'SIGNED',
expired: 'EXPIRED',
void: 'VOIDED',
@@ -1187,6 +1226,8 @@ export async function createSigningTask(input: CreateSigningTaskInput): Promise<
signerName: patient?.name ?? '演示患者',
// 原型新建任务固定落「本人」(发起弹窗没有代签人选择,代签是签署现场才知道的事)
signerType: '本人',
// 需科室确认由文书模板标记带出(原型 ntDocNeedConfirm),不是弹窗里选的
needConfirm: input.needConfirm ?? false,
method: input.method,
status: 'pending',
source: '工作台发起',
@@ -1238,7 +1279,9 @@ export function completeSigningTask(
return Promise.resolve(null)
}
task.status = 'signed'
// 患者签完之后落哪个状态由文书的「需科室确认」标记决定:
// 需要科室复核的先停在 confirming,等科室签章才算归档(原型 padTask.status 同款写法)
task.status = task.needConfirm ? 'confirming' : 'signed'
task.signatureDataUrl = signatureDataUrl
task.signedAt = formatMockDate()
task.updatedAt = task.signedAt
@@ -1250,6 +1293,34 @@ export function completeSigningTask(
return Promise.resolve(cloneTask(task))
}
/**
* 科室复核签章(原型 confirmDept)。
*
* 「待科室确认」是个**节点**不是终态:科室确认完才转「已签署」、文档才归档。
* 真实模式暂未接入(后端要重做),按项目既有口径如实提示"待接入",
* 不前端自己把状态改成已签署 —— 那是要留痕的正式动作。
*/
export async function confirmDepartmentSigning(id: string): Promise<SigningTaskRecord | null> {
if (!useMockData) {
throw new Error('当前后端未提供科室复核签章接口')
}
const task = mockRecords.find((record) => record.id === id)
if (!task) {
return Promise.resolve(null)
}
if (task.status !== 'confirming') {
throw new Error('只有「待科室确认」的任务需要科室复核签章')
}
task.status = 'signed'
task.updatedAt = formatMockDate()
persistMockRecords()
return Promise.resolve(cloneTask(task))
}
export async function reopenSigningTask(
id: string,
options: SigningTaskActionOptions = {},
@@ -1371,6 +1442,25 @@ export async function getSigningTaskEvents(id: string): Promise<SigningTaskEvent
})
}
// 需科室确认的任务:签完先入科室确认队列,科室签章后才归档(原型操作留痕同款两条)
if (task.needConfirm && task.signedAt) {
events.push({
id: `${task.id}-confirm-queued`,
time: task.signedAt,
text: `已推送至${task.department}确认队列,等待科室复核签章`,
eventType: 'CONFIRM_QUEUED',
})
if (task.status === 'signed') {
events.push({
id: `${task.id}-confirmed`,
time: task.updatedAt,
text: '科室复核签章完成,文档归档至本次诊疗记录',
eventType: 'CONFIRMED',
})
}
}
if (task.status === 'void') {
events.push({
id: `${task.id}-voided`,
@@ -1387,6 +1477,7 @@ export function getSigningStatusLabel(status: SigningTaskStatus) {
const labels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
confirming: '待科室确认',
signed: '已签署',
expired: '已超时',
void: '已作废',
+43 -2
View File
@@ -18,7 +18,22 @@ export interface WorkbenchOverviewRequestDto {
rankingLimit?: number
}
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'expired' | 'void' | 'failed'
/**
* 签署任务状态(原型 V10.2 的六种 + 「待科室确认」)。
*
* `confirming` 是**节点状态**,不是失败态:检验 / 基因检测 / 自费等场景要求
* 患者签完之后由科室再复核签章(原型 `needConfirm`),此时文书已经签好、只差科室那一道章。
* 它与 `signed` 的区别是「能不能算最终归档」——`signed` 才能下载归档原件,
* `confirming` 只能下载患者已签版本。
*/
export type SigningTaskStatus =
| 'pending'
| 'signing'
| 'confirming'
| 'signed'
| 'expired'
| 'void'
| 'failed'
export type PatientSex = '男' | '女' | '未知'
@@ -67,6 +82,11 @@ export interface SigningTemplate {
category: string
description: string
supportedMethods: SigningMethod[]
/**
* 患者签署后是否需要科室复核签章(原型 needConfirm)。
* 发起签署时随文书带到任务上,决定签完落「待科室确认」还是「已签署」。
*/
needConfirm?: boolean
/** 原型:文书定稿 HTML 随模板携带,发起签署时进入任务快照。 */
contentHtml?: string
/**
@@ -258,6 +278,13 @@ export interface SigningTaskRecord extends WorkbenchTask {
*/
signerType?: string | null
signerRelation?: string | null
/**
* 是否需要在患者签署后增加「科室复核签章」节点(原型 needConfirm,由文书模板标记带出)。
*
* 为 true 时患者签完落 `confirming` 而不是 `signed`,要等科室确认完才算归档。
* 取不到即按 false 处理(走原路直接归档),不凭空多插一道人工节点。
*/
needConfirm?: boolean
method: SigningMethod
/**
* 短信发送到的号码,后端只返回脱敏值(如 `138****5678`)。
@@ -337,6 +364,8 @@ export interface CreateSigningTaskInput {
department: string
/** 页面快照字段:仅 Mock 模式进入任务记录;真实接口不提交(后端按 templateVersionId 取版本快照)。 */
documentContentHtml?: string
/** 由文书模板带出的「需科室确认」标记;真实接口不提交(后端按 templateVersionId 取)。 */
needConfirm?: boolean
}
/** MEDISIGN 创建签署任务请求 DTO。不要向后端提交页面快照字段。 */
@@ -362,7 +391,15 @@ export interface SigningTaskActionOptions extends SigningApiOptions {
}
export type BackendSigningTaskStatus =
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED'
| 'CREATED'
| 'WAITING_SIGN'
| 'SIGNED'
| 'EXPIRED'
| 'VOIDED'
| 'GENERATING'
| 'FAILED'
/** 患者已签、等科室复核签章(对应前端 confirming)。后端重做时按这个取值对齐。 */
| 'WAITING_CONFIRM'
/**
* 后端签署方式,与前端 SigningMethod 一一对应(大小写之差)。
@@ -421,6 +458,8 @@ export interface AvailableTemplateVersionResponseDto {
versionNo: string
contentSha256: string
effectiveAt: string
/** 患者签署后是否需要科室复核签章;后端补齐后前端无需改动即可带出。 */
needConfirm?: boolean | null
}
export interface SignTaskPatientSnapshotDto {
@@ -474,6 +513,8 @@ export interface SignTaskResponseDto {
*/
signerType?: string | null
signerRelation?: string | null
/** 患者签署后是否需要科室复核签章(由文书模板标记带出)。 */
needConfirm?: boolean | null
signedAt: string | null
expiredAt: string | null
voidedAt: string | null