Move claim/document/persist/proposal/read/review_flow into ≤800-line siblings without behavior change; short tests green.
275 lines
8.1 KiB
Go
275 lines
8.1 KiB
Go
package docgov
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ProposeModification 当文档处于已认领状态时,由智能体或系统提出变更提案
|
|
func (e *GovernanceEngine) ProposeModification(
|
|
ctx context.Context,
|
|
docID string,
|
|
triggeredBy string,
|
|
proposedChanges string,
|
|
impactAnalysis string,
|
|
riskAssessment string,
|
|
) (*ChangeProposal, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
doc, exists := e.memDocs[docID]
|
|
if !exists {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
|
if err != nil || doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
}
|
|
|
|
proposalID := fmt.Sprintf("prop_%s_%d", doc.TaskKey, time.Now().UnixNano())
|
|
now := time.Now()
|
|
|
|
proposal := &ChangeProposal{
|
|
ProposalID: proposalID,
|
|
DocID: docID,
|
|
ProjectID: doc.ProjectID,
|
|
TriggeredBy: triggeredBy,
|
|
ProposedChanges: proposedChanges,
|
|
ImpactAnalysis: impactAnalysis,
|
|
RiskAssessment: riskAssessment,
|
|
Status: ProposalPendingAuthorApproval,
|
|
CreatedAt: now,
|
|
}
|
|
|
|
e.memProps[proposalID] = proposal
|
|
|
|
// 记录审计
|
|
audit := &AuditLog{
|
|
DocID: docID,
|
|
Action: "propose_change",
|
|
ActorID: 0,
|
|
ActorName: triggeredBy,
|
|
ActorRole: RoleSystem,
|
|
FromState: doc.CurrentState,
|
|
ToState: doc.CurrentState,
|
|
Details: map[string]any{
|
|
"proposal_id": proposalID,
|
|
"proposed_changes": proposedChanges,
|
|
"impact": impactAnalysis,
|
|
"risk": riskAssessment,
|
|
},
|
|
CreatedAt: now,
|
|
}
|
|
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
|
|
|
if e.pool != nil {
|
|
query := `
|
|
INSERT INTO document_change_proposals (
|
|
proposal_id, doc_id, project_id, triggered_by, proposed_changes, impact_analysis, risk_assessment, status, created_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id;
|
|
`
|
|
var id int64
|
|
if err := e.pool.QueryRow(ctx, query,
|
|
proposal.ProposalID, proposal.DocID, proposal.ProjectID,
|
|
proposal.TriggeredBy, proposal.ProposedChanges, proposal.ImpactAnalysis, proposal.RiskAssessment,
|
|
string(proposal.Status), proposal.CreatedAt,
|
|
).Scan(&id); err == nil {
|
|
proposal.ID = id
|
|
}
|
|
|
|
detJSON, _ := json.Marshal(audit.Details)
|
|
if _, err := e.pool.Exec(ctx, `
|
|
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
|
|
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
|
|
slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err)
|
|
}
|
|
}
|
|
|
|
return proposal, nil
|
|
}
|
|
|
|
// DecideModificationProposal 编制负责人拍板决定:同意修改或拒绝修改
|
|
func (e *GovernanceEngine) DecideModificationProposal(
|
|
ctx context.Context,
|
|
proposalID string,
|
|
authorID int64,
|
|
approve bool,
|
|
reason string,
|
|
) (*ChangeProposal, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
proposal, exists := e.memProps[proposalID]
|
|
if !exists && e.pool != nil {
|
|
var p ChangeProposal
|
|
var stat string
|
|
err := e.pool.QueryRow(ctx, `
|
|
SELECT id, proposal_id, doc_id, project_id, triggered_by, proposed_changes, impact_analysis, risk_assessment, status, author_decision_reason, decided_at, created_at
|
|
FROM document_change_proposals
|
|
WHERE proposal_id = $1
|
|
`, proposalID).Scan(&p.ID, &p.ProposalID, &p.DocID, &p.ProjectID, &p.TriggeredBy, &p.ProposedChanges, &p.ImpactAnalysis, &p.RiskAssessment, &stat, &p.AuthorDecisionReason, &p.DecidedAt, &p.CreatedAt)
|
|
if err == nil {
|
|
p.Status = ProposalStatus(stat)
|
|
proposal = &p
|
|
e.memProps[proposalID] = &p
|
|
exists = true
|
|
}
|
|
}
|
|
if !exists {
|
|
return nil, ErrProposalNotFound
|
|
}
|
|
if proposal.Status != ProposalPendingAuthorApproval {
|
|
return nil, ErrProposalAlreadyDecided
|
|
}
|
|
|
|
doc, docExists := e.memDocs[proposal.DocID]
|
|
if !docExists && e.pool != nil {
|
|
var err error
|
|
doc, err = e.loadDocumentFromDBLocked(ctx, proposal.DocID)
|
|
if err == nil && doc != nil {
|
|
docExists = true
|
|
}
|
|
}
|
|
if !docExists {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
|
|
// 校验只有编制人有权拍板
|
|
if doc.AuthorID > 0 && authorID > 0 && doc.AuthorID != authorID {
|
|
return nil, fmt.Errorf("%w: 只有编制负责人 (ID: %d) 有权拍板该修改提案", ErrUnauthorizedActor, doc.AuthorID)
|
|
}
|
|
|
|
now := time.Now()
|
|
proposal.AuthorDecisionReason = strings.TrimSpace(reason)
|
|
proposal.DecidedAt = &now
|
|
|
|
var action string
|
|
if approve {
|
|
proposal.Status = ProposalApprovedByAuthor
|
|
action = "author_approve_proposal"
|
|
} else {
|
|
proposal.Status = ProposalRejectedByAuthor
|
|
action = "author_reject_proposal"
|
|
}
|
|
|
|
audit := &AuditLog{
|
|
DocID: proposal.DocID,
|
|
Action: action,
|
|
ActorID: authorID,
|
|
ActorName: doc.AuthorName,
|
|
ActorRole: RoleAuthor,
|
|
FromState: doc.CurrentState,
|
|
ToState: doc.CurrentState,
|
|
Details: map[string]any{
|
|
"proposal_id": proposalID,
|
|
"decision": approve,
|
|
"reason": proposal.AuthorDecisionReason,
|
|
"timestamp": now,
|
|
},
|
|
CreatedAt: now,
|
|
}
|
|
e.memAudits[proposal.DocID] = append(e.memAudits[proposal.DocID], audit)
|
|
|
|
if e.pool != nil {
|
|
if _, err := e.pool.Exec(ctx, `
|
|
UPDATE document_change_proposals
|
|
SET status = $1, author_decision_reason = $2, decided_at = $3
|
|
WHERE proposal_id = $4
|
|
`, string(proposal.Status), proposal.AuthorDecisionReason, proposal.DecidedAt, proposalID); err != nil {
|
|
slog.Error("docgov_proposal_decide_persist_failed", "proposal_id", proposalID, "error", err)
|
|
}
|
|
|
|
detJSON, _ := json.Marshal(audit.Details)
|
|
if _, err := e.pool.Exec(ctx, `
|
|
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
|
|
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
|
|
slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err)
|
|
}
|
|
}
|
|
|
|
return proposal, nil
|
|
}
|
|
|
|
// AddComment 添加批注与修改意见 (审核人/批准人/编制人均可批注,但审核批准不能直接改正文)
|
|
// 有效编辑租约期间禁止批注;生命周期锁定下仍允许批注。
|
|
func (e *GovernanceEngine) AddComment(
|
|
ctx context.Context,
|
|
docID string,
|
|
userID int64,
|
|
userName string,
|
|
userRole UserRole,
|
|
targetSection string,
|
|
commentType CommentType,
|
|
content string,
|
|
) (*DocumentComment, error) {
|
|
if err := e.AssertWritableForComment(ctx, docID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
trimmed := strings.TrimSpace(content)
|
|
if trimmed == "" {
|
|
return nil, fmt.Errorf("comment content cannot be empty")
|
|
}
|
|
|
|
now := time.Now()
|
|
comment := &DocumentComment{
|
|
DocID: docID,
|
|
UserID: userID,
|
|
UserName: userName,
|
|
UserRole: userRole,
|
|
TargetSection: targetSection,
|
|
CommentType: commentType,
|
|
Content: trimmed,
|
|
Status: "open",
|
|
CreatedAt: now,
|
|
}
|
|
|
|
e.memComms[docID] = append(e.memComms[docID], comment)
|
|
|
|
if e.pool != nil {
|
|
query := `
|
|
INSERT INTO document_comments (doc_id, user_id, user_name, user_role, target_section, comment_type, content, status, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id;
|
|
`
|
|
var id int64
|
|
if err := e.pool.QueryRow(ctx, query,
|
|
comment.DocID, comment.UserID, comment.UserName, string(comment.UserRole),
|
|
comment.TargetSection, string(comment.CommentType), comment.Content, comment.Status, comment.CreatedAt,
|
|
).Scan(&id); err == nil {
|
|
comment.ID = id
|
|
}
|
|
}
|
|
|
|
return comment, nil
|
|
}
|
|
|
|
func (e *GovernanceEngine) addCommentInternal(docID string, userID int64, userName string, userRole UserRole, targetSection string, commentType CommentType, content string) error {
|
|
now := time.Now()
|
|
comment := &DocumentComment{
|
|
DocID: docID,
|
|
UserID: userID,
|
|
UserName: userName,
|
|
UserRole: userRole,
|
|
TargetSection: targetSection,
|
|
CommentType: commentType,
|
|
Content: content,
|
|
Status: "open",
|
|
CreatedAt: now,
|
|
}
|
|
e.memComms[docID] = append(e.memComms[docID], comment)
|
|
return nil
|
|
}
|