Files
weichenandCursor a2aa3d41bd feat(projectinstance): support per-doc author_only duty policy
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>
2026-09-17 09:39:59 +00:00

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
}