diff --git a/internal/projectinstance/document_access.go b/internal/projectinstance/document_access.go index 380fcdc3..8e29aa60 100644 --- a/internal/projectinstance/document_access.go +++ b/internal/projectinstance/document_access.go @@ -15,6 +15,7 @@ const ( AccessCodeDocTaskDisabled = "DOC_TASK_DISABLED" AccessCodeNotCurrentStage = "DOC_TASK_NOT_IN_ACTIVE_STAGE" AccessCodeForbiddenDuty = "DOC_DUTY_FORBIDDEN" + AccessCodeDutyNotManaged = "DOC_DUTY_NOT_MANAGED" AccessCodeInstanceClosed = "WORKFLOW_INSTANCE_CLOSED" ) @@ -112,6 +113,26 @@ func (s *Service) CheckDocTaskAccess( } } + if classifyDocDuty(planTask.Duties) == DutyModeNone { + switch action { + case DocAccessActionRun, DocAccessActionEdit: + if isCreator { + return allow + } + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeForbiddenDuty, + Message: "未纳入人工审批的文档仅创建者可编辑或执行", + } + case DocAccessActionReview, DocAccessActionApprove, DocAccessActionSubmit: + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeDutyNotManaged, + Message: "该文档未纳入人工审批,不能审核、批准或提交审批", + } + } + } + if isCreator { return allow } diff --git a/internal/projectinstance/duty_mode.go b/internal/projectinstance/duty_mode.go index 754705b7..09bdbe51 100644 --- a/internal/projectinstance/duty_mode.go +++ b/internal/projectinstance/duty_mode.go @@ -20,6 +20,11 @@ const ( issueSeparationOfDutyViolation = "SEPARATION_OF_DUTY_VIOLATION" ) +// ClassifyDocDuty 供运行时/门禁复用,禁止平行再写一套三职判定。 +func ClassifyDocDuty(d DocDuty) DutyMode { + return classifyDocDuty(d) +} + func classifyDocDuty(d DocDuty) DutyMode { ids := []int64{d.AuthorUserID, d.ReviewerUserID, d.ApproverUserID} assigned := 0 diff --git a/internal/projectinstance/gate.go b/internal/projectinstance/gate.go index 5eefd7c5..a9de4029 100644 --- a/internal/projectinstance/gate.go +++ b/internal/projectinstance/gate.go @@ -25,18 +25,20 @@ func evaluateStageReadiness( continue } td, hasDef := taskDefMap[dt.TaskKey] - required := !hasDef || td.BlocksWhenEnabled() + approvalManaged := classifyDocDuty(dt.Duties) != DutyModeNone + required := approvalManaged && (!hasDef || td.BlocksWhenEnabled()) state := docStates[dt.TaskKey] if state == "" { state = "missing" } approved := state == "approved" item := DocTaskReadiness{ - TaskKey: dt.TaskKey, - Required: required, - Enabled: true, - DocState: state, - Approved: approved, + TaskKey: dt.TaskKey, + Required: required, + Enabled: true, + ApprovalManaged: approvalManaged, + DocState: state, + Approved: approved, } readiness = append(readiness, item) if required && !approved { diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go index 8fae9cda..69246f62 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -59,12 +59,13 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI } status := stageStatus[stPlan.StageKey] hasDuty := stageDutyForUser(stPlan, userID) + hasUnmanaged := stageHasUnmanagedDoc(stPlan) allow := false switch status { case StageActive: - allow = isCreator || hasDuty + allow = isCreator || hasDuty || hasUnmanaged case StageCompleted: - allow = hasDuty || isCreator + allow = hasDuty || isCreator || hasUnmanaged default: allow = false } @@ -76,7 +77,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI if !dt.Enabled { continue } - if isCreator || docTaskDutyForUser(dt, userID) { + if isCreator || docTaskDutyForUser(dt, userID) || classifyDocDuty(dt.Duties) == DutyModeNone { out.AllowedTaskKeys[dt.TaskKey] = true } } @@ -94,11 +95,23 @@ func stageDutyForUser(st StagePlan, userID int64) bool { } func docTaskDutyForUser(dt DocTaskPlan, userID int64) bool { + if classifyDocDuty(dt.Duties) != DutyModeFull { + return false + } return dt.Duties.AuthorUserID == userID || dt.Duties.ReviewerUserID == userID || dt.Duties.ApproverUserID == userID } +func stageHasUnmanagedDoc(st StagePlan) bool { + for _, dt := range st.DocTasks { + if dt.Enabled && classifyDocDuty(dt.Duties) == DutyModeNone { + return true + } + } + return false +} + // TaskKeyAllowed 判断 task_key 是否在允许范围内。 func (a *StageAccess) TaskKeyAllowed(taskKey string) bool { if a == nil || !a.Restricted { diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index 64e89b42..d5eb30c1 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -296,7 +296,7 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst * if err != nil { return err } - if !dt.Enabled { + if !dt.Enabled || classifyDocDuty(dt.Duties) == DutyModeNone { continue } for role, uid := range map[string]int64{ @@ -960,9 +960,9 @@ func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projec return nil, ErrInstanceNotFound } now := time.Now() - inst.Status = "terminated" + inst.Status = InstanceTerminated copyInst := *inst - delete(m.live, projectID) + m.live[projectID] = ©Inst pm := m.ensureMeta(projectID) pm.lifecycle = LifecyclePlanning pm.activeKey = "" diff --git a/internal/projectinstance/types.go b/internal/projectinstance/types.go index bded0cef..9711e24a 100644 --- a/internal/projectinstance/types.go +++ b/internal/projectinstance/types.go @@ -110,15 +110,24 @@ type ActivationPreview struct { ExpiresAt time.Time `json:"expires_at"` } +// PreviewDocRef 预览中列出的例外文档。 +type PreviewDocRef struct { + TaskKey string `json:"task_key"` + Title string `json:"title"` +} + // PreviewSummary 激活影响摘要。 type PreviewSummary struct { - WorkflowName string `json:"workflow_name"` - WorkflowVersion int `json:"workflow_version"` - EnabledStages int `json:"enabled_stages"` - EnabledDocTasks int `json:"enabled_doc_tasks"` - AssignedDuties int `json:"assigned_duties"` - FirstStageKey string `json:"first_stage_key,omitempty"` - FirstStageName string `json:"first_stage_name,omitempty"` + WorkflowName string `json:"workflow_name"` + WorkflowVersion int `json:"workflow_version"` + EnabledStages int `json:"enabled_stages"` + EnabledDocTasks int `json:"enabled_doc_tasks"` + GovernedDocTasks int `json:"governed_doc_tasks"` + UnmanagedDocTasks int `json:"unmanaged_doc_tasks"` + AssignedDuties int `json:"assigned_duties"` + UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"` + FirstStageKey string `json:"first_stage_key,omitempty"` + FirstStageName string `json:"first_stage_name,omitempty"` } // ActivateInput 二次确认激活。 @@ -199,11 +208,12 @@ type GateIssue struct { // DocTaskReadiness 文档任务就绪状态。 type DocTaskReadiness struct { - TaskKey string `json:"task_key"` - Required bool `json:"required"` - Enabled bool `json:"enabled"` - DocState string `json:"doc_state"` - Approved bool `json:"approved"` + TaskKey string `json:"task_key"` + Required bool `json:"required"` + Enabled bool `json:"enabled"` + ApprovalManaged bool `json:"approval_managed"` + DocState string `json:"doc_state"` + Approved bool `json:"approved"` } // StageReadiness 阶段推进就绪评估。 diff --git a/internal/projectinstance/validator.go b/internal/projectinstance/validator.go index 5edfd4eb..5d9753bf 100644 --- a/internal/projectinstance/validator.go +++ b/internal/projectinstance/validator.go @@ -237,14 +237,24 @@ func previewSummary(plan WorkflowPlan, version *workflowdef.Version) PreviewSumm continue } sum.EnabledDocTasks++ - if dt.Duties.AuthorUserID > 0 { - sum.AssignedDuties++ - } - if dt.Duties.ReviewerUserID > 0 { - sum.AssignedDuties++ - } - if dt.Duties.ApproverUserID > 0 { - sum.AssignedDuties++ + switch classifyDocDuty(dt.Duties) { + case DutyModeFull: + sum.GovernedDocTasks++ + if dt.Duties.AuthorUserID > 0 { + sum.AssignedDuties++ + } + if dt.Duties.ReviewerUserID > 0 { + sum.AssignedDuties++ + } + if dt.Duties.ApproverUserID > 0 { + sum.AssignedDuties++ + } + case DutyModeNone: + sum.UnmanagedDocTasks++ + sum.UnmanagedDocuments = append(sum.UnmanagedDocuments, PreviewDocRef{ + TaskKey: dt.TaskKey, + Title: dutyTaskLabel(dt), + }) } } } diff --git a/web/src/components/project/ProjectSettingsModal.tsx b/web/src/components/project/ProjectSettingsModal.tsx index 100b1642..e54d9399 100644 --- a/web/src/components/project/ProjectSettingsModal.tsx +++ b/web/src/components/project/ProjectSettingsModal.tsx @@ -538,7 +538,15 @@ export const ProjectSettingsModal: React.FC = ({
工作流:{preview.summary.workflow_name} v{preview.summary.workflow_version}
启用阶段:{preview.summary.enabled_stages} · 文档任务:{preview.summary.enabled_doc_tasks}
+
+ 纳入人工审批:{preview.summary.governed_doc_tasks ?? preview.summary.enabled_doc_tasks} · 不纳入:{preview.summary.unmanaged_doc_tasks ?? 0} +
职责指派:{preview.summary.assigned_duties} 项
+ {(preview.summary.unmanaged_documents || []).length > 0 && ( +
+ 例外文档:{(preview.summary.unmanaged_documents || []).map((doc) => doc.title || doc.task_key).join('、')} +
+ )} {preview.summary.first_stage_name && (
首阶段:{preview.summary.first_stage_name}
)} diff --git a/web/src/types/projectSettings.ts b/web/src/types/projectSettings.ts index 440f4335..2c4f4d1c 100644 --- a/web/src/types/projectSettings.ts +++ b/web/src/types/projectSettings.ts @@ -81,7 +81,10 @@ export interface ActivationPreview { workflow_version: number; enabled_stages: number; enabled_doc_tasks: number; + governed_doc_tasks?: number; + unmanaged_doc_tasks?: number; assigned_duties: number; + unmanaged_documents?: Array<{ task_key: string; title: string }>; first_stage_key?: string; first_stage_name?: string; }; @@ -124,6 +127,7 @@ export interface StageReadiness { task_key: string; required: boolean; enabled: boolean; + approval_managed?: boolean; doc_state: string; approved: boolean; }>;