diff --git a/web/src/lib/projectSettingsDuties.ts b/web/src/lib/projectSettingsDuties.ts index b7eb24c1..98ed3a9c 100644 --- a/web/src/lib/projectSettingsDuties.ts +++ b/web/src/lib/projectSettingsDuties.ts @@ -12,17 +12,62 @@ export function classifyClientDuty(duty?: DocDuty): ClientDutyMode { return 'partial'; } +/** 对齐 Go ResolveDocTaskDuty:author_only 仅要求编制人,有效三职派生为 A/A/A。 */ +export function resolveClientDocTaskDuty(task: DocTaskPlan): { + mode: ClientDutyMode; + effective: DocDuty; + isAuthorOnly: boolean; +} { + if (!task.enabled) { + return { mode: 'none', effective: {}, isAuthorOnly: false }; + } + const policy = (task.duty_policy || '').trim(); + if (policy === 'author_only') { + const author = task.duties?.author_user_id ?? 0; + if (author <= 0) { + return { mode: 'partial', effective: {}, isAuthorOnly: true }; + } + return { + mode: 'full', + effective: { + author_user_id: author, + reviewer_user_id: author, + approver_user_id: author, + }, + isAuthorOnly: true, + }; + } + if (policy && policy !== 'separated') { + return { mode: 'partial', effective: {}, isAuthorOnly: false }; + } + const mode = classifyClientDuty(task.duties); + return { mode, effective: { ...(task.duties || {}) }, isAuthorOnly: false }; +} + export function canToggleHumanApproval(task: Pick, planning: boolean): boolean { return planning && Boolean(task.approval_configurable); } export function toggleHumanApproval(task: DocTaskPlan, managed: boolean): DocTaskPlan { if (!managed) { - return { ...task, duties: {} }; + return { ...task, duty_policy: '', duties: {} }; } return { ...task }; } +/** 切换兼任模式:开启时只保留编制人并清空审核/批准;关闭时清除 duty_policy。 */ +export function toggleAuthorOnly(task: DocTaskPlan, enabled: boolean): DocTaskPlan { + if (!enabled) { + return { ...task, duty_policy: '' }; + } + const author = task.duties?.author_user_id; + return { + ...task, + duty_policy: 'author_only', + duties: author ? { author_user_id: author } : {}, + }; +} + export function clientDutyIssues( plan: WorkflowPlan, members: Array<{ userId: number }>, @@ -35,9 +80,50 @@ export function clientDutyIssues( .flatMap((stage) => stage.doc_tasks.filter((task) => task.enabled)); for (const task of enabled) { - const mode = classifyClientDuty(task.duties); const field = `${task.task_key}.duties`; const label = task.task_title || task.task_key; + const policy = (task.duty_policy || '').trim(); + + if (policy && policy !== 'separated' && policy !== 'author_only') { + issues.push({ + code: 'UNKNOWN_DUTY_POLICY', + field: `${task.task_key}.duty_policy`, + message: `文档「${label}」的职责模式无效`, + }); + continue; + } + + if (policy === 'author_only') { + const author = task.duties?.author_user_id ?? 0; + if (author <= 0) { + issues.push({ + code: 'AUTHOR_ONLY_AUTHOR_REQUIRED', + field, + message: `文档「${label}」兼任模式必须指定编制人`, + }); + continue; + } + const reviewer = task.duties?.reviewer_user_id ?? 0; + const approver = task.duties?.approver_user_id ?? 0; + if ((reviewer > 0 && reviewer !== author) || (approver > 0 && approver !== author)) { + issues.push({ + code: 'AUTHOR_ONLY_DUTY_CONFLICT', + field, + message: `文档「${label}」兼任模式不能指定与编制人不同的审核人或批准人`, + }); + continue; + } + if (!memberIDs.has(author)) { + issues.push({ + code: 'DUTY_ASSIGNEE_NOT_ACTIVE_MEMBER', + field: `${task.task_key}.author`, + message: `文档「${label}」的负责人不是当前成员`, + }); + } + continue; + } + + const { mode, effective } = resolveClientDocTaskDuty(task); if (mode === 'partial') { issues.push({ code: 'DUTY_PARTIAL_ASSIGNMENT', @@ -55,7 +141,7 @@ export function clientDutyIssues( continue; } if (mode === 'full') { - const ids = [task.duties.author_user_id, task.duties.reviewer_user_id, task.duties.approver_user_id]; + const ids = [effective.author_user_id, effective.reviewer_user_id, effective.approver_user_id]; if (new Set(ids).size !== ids.length) { issues.push({ code: 'SEPARATION_OF_DUTY_VIOLATION', @@ -64,9 +150,9 @@ export function clientDutyIssues( }); } for (const [role, id] of [ - ['author', task.duties.author_user_id], - ['reviewer', task.duties.reviewer_user_id], - ['approver', task.duties.approver_user_id], + ['author', effective.author_user_id], + ['reviewer', effective.reviewer_user_id], + ['approver', effective.approver_user_id], ] as const) { if (id && !memberIDs.has(id)) { issues.push({ @@ -82,13 +168,16 @@ export function clientDutyIssues( } export function needsThreeMembersForDuties(plan: WorkflowPlan, requireApprovalKeys: string[] = []): boolean { - return plan.stages.some((stage) => - stage.enabled && - stage.doc_tasks.some((task) => { - if (!task.enabled) return false; - if (!task.approval_configurable || requireApprovalKeys.includes(task.task_key)) return true; - return classifyClientDuty(task.duties) === 'full'; - }), + return plan.stages.some( + (stage) => + stage.enabled && + stage.doc_tasks.some((task) => { + if (!task.enabled) return false; + const { mode, isAuthorOnly } = resolveClientDocTaskDuty(task); + if (isAuthorOnly) return false; + if (!task.approval_configurable || requireApprovalKeys.includes(task.task_key)) return true; + return mode === 'full'; + }), ); } diff --git a/web/src/types/projectSettings.ts b/web/src/types/projectSettings.ts index f060febb..71246f5a 100644 --- a/web/src/types/projectSettings.ts +++ b/web/src/types/projectSettings.ts @@ -4,11 +4,15 @@ export interface DocDuty { approver_user_id?: number; } +export type DutyPolicy = 'separated' | 'author_only'; + export interface DocTaskPlan { task_key: string; task_title?: string; enabled: boolean; approval_configurable?: boolean; + /** 逐文档职责模式;缺省/空值按 separated 解读。 */ + duty_policy?: DutyPolicy | ''; duties: DocDuty; } @@ -48,6 +52,12 @@ export interface WorkflowEventSummary { created_at: string; } +export interface GovernancePolicyCapability { + allowed: boolean; + revision: number; + blocked_reason?: string; +} + export interface ProjectSettings { project_id: number; lifecycle_status: string; @@ -62,6 +72,7 @@ export interface ProjectSettings { live_instance?: { id: number; status: string; config_revision: number }; published_workflows?: PublishedOption[]; recent_workflow_events?: WorkflowEventSummary[]; + author_only_policy?: GovernancePolicyCapability; } export interface TerminationPreview { @@ -72,6 +83,12 @@ export interface TerminationPreview { impact?: string; } +export interface PreviewDocRef { + task_key: string; + title: string; + author_user_id?: number; +} + export interface ActivationPreview { confirmation_token: string; plan_hash: string; @@ -85,7 +102,9 @@ export interface ActivationPreview { governed_doc_tasks?: number; unmanaged_doc_tasks?: number; assigned_duties: number; - unmanaged_documents?: Array<{ task_key: string; title: string }>; + author_only_doc_tasks?: number; + unmanaged_documents?: PreviewDocRef[]; + author_only_documents?: PreviewDocRef[]; first_stage_key?: string; first_stage_name?: string; };