Add ResolveDocTaskDuty, require the system policy on save/preview, lock the policy row during activation, and derive A/A/A assignments. Co-authored-by: Cursor <cursoragent@cursor.com>
240 lines
6.3 KiB
Go
240 lines
6.3 KiB
Go
package projectinstance
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"chat-agent-system/internal/workflowdef"
|
|
)
|
|
|
|
type DutyMode string
|
|
|
|
const (
|
|
DutyModeNone DutyMode = "none"
|
|
DutyModePartial DutyMode = "partial"
|
|
DutyModeFull DutyMode = "full"
|
|
|
|
issueDutyPartialAssignment = "DUTY_PARTIAL_ASSIGNMENT"
|
|
issueDutyAssignmentRequired = "DUTY_ASSIGNMENT_REQUIRED"
|
|
issueNoGovernedDocument = "NO_GOVERNED_DOCUMENT"
|
|
issueDutyAssigneeNotActive = "DUTY_ASSIGNEE_NOT_ACTIVE_MEMBER"
|
|
issueSeparationOfDutyViolation = "SEPARATION_OF_DUTY_VIOLATION"
|
|
issueUnknownDutyPolicy = "UNKNOWN_DUTY_POLICY"
|
|
issueAuthorOnlyAuthorRequired = "AUTHOR_ONLY_AUTHOR_REQUIRED"
|
|
issueAuthorOnlyDutyConflict = "AUTHOR_ONLY_DUTY_CONFLICT"
|
|
)
|
|
|
|
// ClassifyDocDuty 供运行时/门禁复用,禁止平行再写一套三职判定。
|
|
// 仅按三职 ID 完整度分类;治理调用应改用 ResolveDocTaskDuty。
|
|
func ClassifyDocDuty(d DocDuty) DutyMode {
|
|
return classifyDocDuty(d)
|
|
}
|
|
|
|
func classifyDocDuty(d DocDuty) DutyMode {
|
|
ids := []int64{d.AuthorUserID, d.ReviewerUserID, d.ApproverUserID}
|
|
assigned := 0
|
|
for _, id := range ids {
|
|
if id < 0 {
|
|
return DutyModePartial
|
|
}
|
|
if id > 0 {
|
|
assigned++
|
|
}
|
|
}
|
|
switch assigned {
|
|
case 0:
|
|
return DutyModeNone
|
|
case 3:
|
|
return DutyModeFull
|
|
default:
|
|
return DutyModePartial
|
|
}
|
|
}
|
|
|
|
// ResolveDocTaskDuty 按逐文档 duty_policy 解析完整度、有效三职与是否兼任。
|
|
// author_only 的规范存储可只含 author;有效三职派生为 A/A/A。
|
|
func ResolveDocTaskDuty(dt DocTaskPlan) (mode DutyMode, effective DocDuty, isAuthorOnly bool) {
|
|
if !dt.Enabled {
|
|
return DutyModeNone, DocDuty{}, false
|
|
}
|
|
switch dt.DutyPolicy {
|
|
case "", DutyPolicySeparated:
|
|
return classifyDocDuty(dt.Duties), dt.Duties, false
|
|
case DutyPolicyAuthorOnly:
|
|
if dt.Duties.AuthorUserID <= 0 {
|
|
return DutyModePartial, DocDuty{}, true
|
|
}
|
|
a := dt.Duties.AuthorUserID
|
|
return DutyModeFull, DocDuty{AuthorUserID: a, ReviewerUserID: a, ApproverUserID: a}, true
|
|
default:
|
|
return DutyModePartial, DocDuty{}, false
|
|
}
|
|
}
|
|
|
|
func docTaskRequiresDuty(td workflowdef.DocTaskDef) bool {
|
|
return td.Required || td.Blocking || !td.IsCreatorConfigurable()
|
|
}
|
|
|
|
func normalizeDocTaskDuties(dt DocTaskPlan) DocTaskPlan {
|
|
if !dt.Enabled {
|
|
dt.Duties = DocDuty{}
|
|
dt.DutyPolicy = ""
|
|
return dt
|
|
}
|
|
if dt.DutyPolicy == DutyPolicyAuthorOnly {
|
|
a := dt.Duties.AuthorUserID
|
|
if a > 0 {
|
|
if dt.Duties.ReviewerUserID == a {
|
|
dt.Duties.ReviewerUserID = 0
|
|
}
|
|
if dt.Duties.ApproverUserID == a {
|
|
dt.Duties.ApproverUserID = 0
|
|
}
|
|
}
|
|
return dt
|
|
}
|
|
if classifyDocDuty(dt.Duties) == DutyModeNone {
|
|
dt.Duties = DocDuty{}
|
|
}
|
|
return dt
|
|
}
|
|
|
|
func normalizeWorkflowPlanDuties(plan WorkflowPlan) WorkflowPlan {
|
|
for i := range plan.Stages {
|
|
for j := range plan.Stages[i].DocTasks {
|
|
plan.Stages[i].DocTasks[j] = normalizeDocTaskDuties(plan.Stages[i].DocTasks[j])
|
|
}
|
|
}
|
|
return plan
|
|
}
|
|
|
|
func validateDocTaskDuties(dt DocTaskPlan, td workflowdef.DocTaskDef) []workflowdef.ValidationIssue {
|
|
if !dt.Enabled {
|
|
return nil
|
|
}
|
|
field := dt.TaskKey + ".duties"
|
|
switch dt.DutyPolicy {
|
|
case "", DutyPolicySeparated:
|
|
// fall through to separated rules below
|
|
case DutyPolicyAuthorOnly:
|
|
if dt.Duties.AuthorUserID <= 0 {
|
|
return []workflowdef.ValidationIssue{{
|
|
Code: issueAuthorOnlyAuthorRequired,
|
|
Field: field,
|
|
Message: fmt.Sprintf("文档「%s」兼任模式必须指定编制人", dutyTaskLabel(dt)),
|
|
}}
|
|
}
|
|
a := dt.Duties.AuthorUserID
|
|
if (dt.Duties.ReviewerUserID > 0 && dt.Duties.ReviewerUserID != a) ||
|
|
(dt.Duties.ApproverUserID > 0 && dt.Duties.ApproverUserID != a) {
|
|
return []workflowdef.ValidationIssue{{
|
|
Code: issueAuthorOnlyDutyConflict,
|
|
Field: field,
|
|
Message: fmt.Sprintf("文档「%s」兼任模式不能指定与编制人不同的审核人或批准人", dutyTaskLabel(dt)),
|
|
}}
|
|
}
|
|
return nil
|
|
default:
|
|
return []workflowdef.ValidationIssue{{
|
|
Code: issueUnknownDutyPolicy,
|
|
Field: dt.TaskKey + ".duty_policy",
|
|
Message: fmt.Sprintf("文档「%s」的职责模式无效", dutyTaskLabel(dt)),
|
|
}}
|
|
}
|
|
|
|
mode := classifyDocDuty(dt.Duties)
|
|
switch mode {
|
|
case DutyModePartial:
|
|
return []workflowdef.ValidationIssue{{
|
|
Code: issueDutyPartialAssignment,
|
|
Field: field,
|
|
Message: fmt.Sprintf("文档「%s」不能只指派部分职责", dutyTaskLabel(dt)),
|
|
}}
|
|
case DutyModeNone:
|
|
if docTaskRequiresDuty(td) {
|
|
return []workflowdef.ValidationIssue{{
|
|
Code: issueDutyAssignmentRequired,
|
|
Field: field,
|
|
Message: fmt.Sprintf("文档「%s」必须完整指派编制、审核、批准", dutyTaskLabel(dt)),
|
|
}}
|
|
}
|
|
return nil
|
|
default:
|
|
if dt.Duties.AuthorUserID == dt.Duties.ReviewerUserID ||
|
|
dt.Duties.AuthorUserID == dt.Duties.ApproverUserID ||
|
|
dt.Duties.ReviewerUserID == dt.Duties.ApproverUserID {
|
|
return []workflowdef.ValidationIssue{{
|
|
Code: issueSeparationOfDutyViolation,
|
|
Field: dt.TaskKey,
|
|
Message: "作者、审核人、批准人必须分离",
|
|
}}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func countFullDutyDocs(plan WorkflowPlan) int {
|
|
n := 0
|
|
for _, st := range plan.Stages {
|
|
if !st.Enabled {
|
|
continue
|
|
}
|
|
for _, dt := range st.DocTasks {
|
|
if !dt.Enabled {
|
|
continue
|
|
}
|
|
mode, _, _ := ResolveDocTaskDuty(dt)
|
|
if mode == DutyModeFull {
|
|
n++
|
|
}
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func validateActiveMemberDuties(plan WorkflowPlan, active map[int64]struct{}) []workflowdef.ValidationIssue {
|
|
if active == nil {
|
|
return nil
|
|
}
|
|
issues := make([]workflowdef.ValidationIssue, 0)
|
|
for _, st := range plan.Stages {
|
|
if !st.Enabled {
|
|
continue
|
|
}
|
|
for _, dt := range st.DocTasks {
|
|
if !dt.Enabled {
|
|
continue
|
|
}
|
|
mode, effective, _ := ResolveDocTaskDuty(dt)
|
|
if mode != DutyModeFull {
|
|
continue
|
|
}
|
|
for _, item := range []struct {
|
|
id int64
|
|
role string
|
|
label string
|
|
}{
|
|
{effective.AuthorUserID, "author", "编制"},
|
|
{effective.ReviewerUserID, "reviewer", "审核"},
|
|
{effective.ApproverUserID, "approver", "批准"},
|
|
} {
|
|
if _, ok := active[item.id]; ok {
|
|
continue
|
|
}
|
|
issues = append(issues, workflowdef.ValidationIssue{
|
|
Code: issueDutyAssigneeNotActive,
|
|
Field: dt.TaskKey + "." + item.role,
|
|
Message: fmt.Sprintf("文档「%s」的%s负责人不是在任项目成员", dutyTaskLabel(dt), item.label),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return issues
|
|
}
|
|
|
|
func dutyTaskLabel(dt DocTaskPlan) string {
|
|
if dt.TaskTitle != "" {
|
|
return dt.TaskTitle
|
|
}
|
|
return dt.TaskKey
|
|
}
|