Files
Agent-System/internal/docgov/engine.go
T

740 lines
23 KiB
Go

package docgov
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// ModificationCheckResult 智能体或人员发起修改前的认领与授权判定结果
type ModificationCheckResult struct {
AllowedDirectEdit bool `json:"allowed_direct_edit"` // 是否允许直接执行修改
RequiresProposal bool `json:"requires_proposal"` // 是否需要生成变更提案待编制人拍板
AuthorClaimed bool `json:"author_claimed"` // 编制人是否已认领
PendingProposal *ChangeProposal `json:"pending_proposal,omitempty"`
Reason string `json:"reason"`
}
// GovernanceEngine 受控文档编制/审核/批准与认领治理引擎
type GovernanceEngine struct {
pool *pgxpool.Pool
sm *StateMachine
mu sync.RWMutex
memDocs map[string]*DocumentRecord
memComms map[string][]*DocumentComment
memProps map[string]*ChangeProposal
memAudits map[string][]*AuditLog
}
// NewGovernanceEngine 初始化治理引擎
func NewGovernanceEngine(pool *pgxpool.Pool) *GovernanceEngine {
return &GovernanceEngine{
pool: pool,
sm: NewStateMachine(),
memDocs: make(map[string]*DocumentRecord),
memComms: make(map[string][]*DocumentComment),
memProps: make(map[string]*ChangeProposal),
memAudits: make(map[string][]*AuditLog),
}
}
// CreateOrGetDocument 创建或获取受控文档记录
func (e *GovernanceEngine) CreateOrGetDocument(
ctx context.Context,
docID string,
projectID int64,
taskKey string,
title string,
stage string,
workDir string,
authorID int64, authorName string,
reviewerID int64, reviewerName string,
approverID int64, approverName string,
) (*DocumentRecord, error) {
e.mu.Lock()
defer e.mu.Unlock()
now := time.Now()
// 先查内存
if doc, exists := e.memDocs[docID]; exists {
return doc, nil
}
doc := &DocumentRecord{
DocID: docID,
ProjectID: projectID,
TaskKey: taskKey,
Title: title,
Stage: stage,
Version: 1,
CurrentState: StateDrafting,
IsLocked: false,
IsTemplateOnly: true,
WorkDir: workDir,
AuthorID: authorID,
AuthorName: authorName,
ReviewerID: reviewerID,
ReviewerName: reviewerName,
ApproverID: approverID,
ApproverName: approverName,
Config: DefaultDocumentConfig(),
CreatedAt: now,
UpdatedAt: now,
}
// 若有 DB 连接,写 DB 并同步
if e.pool != nil {
cfgJSON, _ := json.Marshal(doc.Config)
query := `
INSERT INTO document_records (
doc_id, project_id, task_key, title, stage, version,
current_state, is_locked, is_template_only, work_dir,
author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name,
config, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (doc_id) DO UPDATE SET
title = EXCLUDED.title,
stage = EXCLUDED.stage,
updated_at = EXCLUDED.updated_at
RETURNING id, current_state, is_locked, is_template_only, author_claimed, reviewer_claimed, approver_claimed;
`
var id int64
var currState string
var isLocked, isTmpl, authClaim, revClaim, appClaim bool
err := e.pool.QueryRow(ctx, query,
doc.DocID, doc.ProjectID, doc.TaskKey, doc.Title, doc.Stage, doc.Version,
string(doc.CurrentState), doc.IsLocked, doc.IsTemplateOnly, doc.WorkDir,
doc.AuthorID, doc.AuthorName, doc.ReviewerID, doc.ReviewerName, doc.ApproverID, doc.ApproverName,
cfgJSON, doc.CreatedAt, doc.UpdatedAt,
).Scan(&id, &currState, &isLocked, &isTmpl, &authClaim, &revClaim, &appClaim)
if err == nil {
doc.ID = id
doc.CurrentState = DocumentState(currState)
doc.IsLocked = isLocked
doc.IsTemplateOnly = isTmpl
doc.AuthorClaimed = authClaim
doc.ReviewerClaimed = revClaim
doc.ApproverClaimed = appClaim
}
}
e.memDocs[docID] = doc
return doc, nil
}
// MarkDocumentContentUpdated 当正文生成或更新时标记正文哈希及是否脱离模板状态
func (e *GovernanceEngine) MarkDocumentContentUpdated(ctx context.Context, docID string, contentSHA256 string, isTemplateOnly bool) error {
e.mu.Lock()
defer e.mu.Unlock()
doc, exists := e.memDocs[docID]
if !exists {
return ErrDocumentNotFound
}
doc.ContentSHA256 = contentSHA256
doc.IsTemplateOnly = isTemplateOnly
doc.UpdatedAt = time.Now()
if e.pool != nil {
_, _ = e.pool.Exec(ctx, `
UPDATE document_records
SET content_sha256 = $1, is_template_only = $2, updated_at = $3
WHERE doc_id = $4
`, contentSHA256, isTemplateOnly, doc.UpdatedAt, docID)
}
return nil
}
// RecordDocumentViewed 记录查看文档行为并触发认领机制
// 核心规则:
// 1. 只有当文档处于【非模板状态】(is_template_only = false) 时,编制负责人查看才标记为 AuthorClaimed = true;模板状态查看不算认领!
// 2. 审核人查看且处于审核阶段,标记 ReviewerClaimed = true
// 3. 批准人查看且处于批准阶段,标记 ApproverClaimed = true
func (e *GovernanceEngine) RecordDocumentViewed(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) {
e.mu.Lock()
defer e.mu.Unlock()
doc, exists := e.memDocs[docID]
if !exists {
return nil, ErrDocumentNotFound
}
now := time.Now()
var auditAction string
switch actorRole {
case RoleAuthor:
doc.AuthorLastViewedAt = &now
auditAction = "author_view"
// 关键认领判定:只限非模板状态
if !doc.IsTemplateOnly && !doc.AuthorClaimed {
doc.AuthorClaimed = true
doc.AuthorClaimedAt = &now
auditAction = "author_claim_by_view"
}
case RoleReviewer:
doc.ReviewerClaimed = true
doc.ReviewerClaimedAt = &now
auditAction = "reviewer_claim_by_view"
case RoleApprover:
doc.ApproverClaimed = true
doc.ApproverClaimedAt = &now
auditAction = "approver_claim_by_view"
default:
auditAction = "view"
}
doc.UpdatedAt = now
// 记录审计日志
audit := &AuditLog{
DocID: docID,
Action: auditAction,
ActorID: actorID,
ActorName: actorName,
ActorRole: actorRole,
FromState: doc.CurrentState,
ToState: doc.CurrentState,
Details: map[string]any{
"is_template_only": doc.IsTemplateOnly,
"author_claimed": doc.AuthorClaimed,
"timestamp": now,
},
CreatedAt: now,
}
e.memAudits[docID] = append(e.memAudits[docID], audit)
if e.pool != nil {
_, _ = e.pool.Exec(ctx, `
UPDATE document_records
SET author_claimed = $1, author_claimed_at = $2, author_last_viewed_at = $3,
reviewer_claimed = $4, reviewer_claimed_at = $5,
approver_claimed = $6, approver_claimed_at = $7,
updated_at = $8
WHERE doc_id = $9
`, doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastViewedAt,
doc.ReviewerClaimed, doc.ReviewerClaimedAt,
doc.ApproverClaimed, doc.ApproverClaimedAt,
doc.UpdatedAt, docID)
detJSON, _ := json.Marshal(audit.Details)
_, _ = 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)
}
return doc, nil
}
// RecordDocumentEdited 编制人编辑文档 (标记认领与编辑时间)
func (e *GovernanceEngine) RecordDocumentEdited(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) {
e.mu.Lock()
defer e.mu.Unlock()
doc, exists := e.memDocs[docID]
if !exists {
return nil, ErrDocumentNotFound
}
if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole); err != nil {
return nil, err
}
now := time.Now()
doc.AuthorLastEditedAt = &now
doc.AuthorClaimed = true
if doc.AuthorClaimedAt == nil {
doc.AuthorClaimedAt = &now
}
doc.UpdatedAt = now
audit := &AuditLog{
DocID: docID,
Action: "author_edit",
ActorID: actorID,
ActorName: actorName,
ActorRole: actorRole,
FromState: doc.CurrentState,
ToState: doc.CurrentState,
Details: map[string]any{"timestamp": now},
CreatedAt: now,
}
e.memAudits[docID] = append(e.memAudits[docID], audit)
if e.pool != nil {
_, _ = e.pool.Exec(ctx, `
UPDATE document_records
SET author_claimed = $1, author_claimed_at = $2, author_last_edited_at = $3, updated_at = $4
WHERE doc_id = $5
`, doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastEditedAt, doc.UpdatedAt, docID)
detJSON, _ := json.Marshal(audit.Details)
_, _ = 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)
}
return doc, nil
}
// CheckModificationPermission 阶段发起修改编制状态文档前,检查认领与权限
// 核心逻辑:
// 1. 若文档未认领 (author_claimed == false),系统不用告知负责人,直接放行智能体小队编辑 (AllowedDirectEdit = true)
// 2. 若文档已认领 (author_claimed == true),系统拦截直接编辑,必须通知编制负责人并生成变更提案 (RequiresProposal = true)
func (e *GovernanceEngine) CheckModificationPermission(ctx context.Context, docID string, actorRole UserRole) (*ModificationCheckResult, error) {
e.mu.RLock()
defer e.mu.RUnlock()
doc, exists := e.memDocs[docID]
if !exists {
return nil, ErrDocumentNotFound
}
// 1. 检查锁定状态
if doc.IsLocked {
return &ModificationCheckResult{
AllowedDirectEdit: false,
RequiresProposal: false,
AuthorClaimed: doc.AuthorClaimed,
Reason: fmt.Sprintf("文档处于【%s】阶段已锁定,禁止任何修改", doc.CurrentState),
}, ErrDocumentLocked
}
// 2. 检查认领状态
if !doc.AuthorClaimed {
// 未认领:智能体小队可静默直接修改
return &ModificationCheckResult{
AllowedDirectEdit: true,
RequiresProposal: false,
AuthorClaimed: false,
Reason: "文档尚未被编制负责人认领(未查看正式稿),允许智能体小队直接自动编辑",
}, nil
}
// 3. 已认领:检查是否有待编制人处理的挂起提案
var pendingProp *ChangeProposal
for _, p := range e.memProps {
if p.DocID == docID && p.Status == ProposalPendingAuthorApproval {
pendingProp = p
break
}
}
return &ModificationCheckResult{
AllowedDirectEdit: false,
RequiresProposal: true,
AuthorClaimed: true,
PendingProposal: pendingProp,
Reason: "文档已被编制负责人认领,必须生成变更提案告知修改点、影响与风险,并由编制人确认授权",
}, nil
}
// 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 {
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)
_, _ = 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)
}
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 {
return nil, ErrProposalNotFound
}
if proposal.Status != ProposalPendingAuthorApproval {
return nil, ErrProposalAlreadyDecided
}
doc, docExists := e.memDocs[proposal.DocID]
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 {
_, _ = 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)
detJSON, _ := json.Marshal(audit.Details)
_, _ = 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)
}
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) {
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
}
// ListComments 列出文档的所有批注
func (e *GovernanceEngine) ListComments(ctx context.Context, docID string) ([]*DocumentComment, error) {
e.mu.RLock()
defer e.mu.RUnlock()
comms := e.memComms[docID]
res := make([]*DocumentComment, len(comms))
copy(res, comms)
return res, nil
}
// SubmitForReview 编制完成提交审核 (drafting -> reviewing)
func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string) (*DocumentRecord, error) {
e.mu.Lock()
defer e.mu.Unlock()
doc, exists := e.memDocs[docID]
if !exists {
return nil, ErrDocumentNotFound
}
audit, err := e.sm.TransitionSubmitForReview(doc, actorID, actorRole, actorName)
if err != nil {
return nil, err
}
e.memAudits[docID] = append(e.memAudits[docID], audit)
e.syncDocAndAuditToDB(ctx, doc, audit)
return doc, nil
}
// ReviewApprove 审核通过 (reviewing -> approving)
func (e *GovernanceEngine) ReviewApprove(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, comment string) (*DocumentRecord, error) {
e.mu.Lock()
defer e.mu.Unlock()
doc, exists := e.memDocs[docID]
if !exists {
return nil, ErrDocumentNotFound
}
audit, err := e.sm.TransitionReviewApprove(doc, actorID, actorRole, actorName, comment)
if err != nil {
return nil, err
}
if strings.TrimSpace(comment) != "" {
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentSuggestion, comment)
}
e.memAudits[docID] = append(e.memAudits[docID], audit)
e.syncDocAndAuditToDB(ctx, doc, audit)
return doc, nil
}
// ReviewReject 审核驳回 (reviewing -> drafting, 驳回原因必填)
func (e *GovernanceEngine) ReviewReject(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, rejectReason string) (*DocumentRecord, error) {
e.mu.Lock()
defer e.mu.Unlock()
doc, exists := e.memDocs[docID]
if !exists {
return nil, ErrDocumentNotFound
}
audit, err := e.sm.TransitionReviewReject(doc, actorID, actorRole, actorName, rejectReason)
if err != nil {
return nil, err
}
// 驳回原因自动持久化为批注记录
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentRejection, rejectReason)
e.memAudits[docID] = append(e.memAudits[docID], audit)
e.syncDocAndAuditToDB(ctx, doc, audit)
return doc, nil
}
// ApproverApprove 批准通过 (approving -> approved, 终态归档锁定)
func (e *GovernanceEngine) ApproverApprove(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, comment string) (*DocumentRecord, error) {
e.mu.Lock()
defer e.mu.Unlock()
doc, exists := e.memDocs[docID]
if !exists {
return nil, ErrDocumentNotFound
}
audit, err := e.sm.TransitionApproverApprove(doc, actorID, actorRole, actorName, comment)
if err != nil {
return nil, err
}
if strings.TrimSpace(comment) != "" {
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentSuggestion, comment)
}
e.memAudits[docID] = append(e.memAudits[docID], audit)
e.syncDocAndAuditToDB(ctx, doc, audit)
return doc, nil
}
// ApproverReject 批准驳回 (approving -> drafting 或 reviewing, 驳回原因必填)
func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, actorID int64, actorRole UserRole, actorName string, rejectReason string, rollbackTo DocumentState) (*DocumentRecord, error) {
e.mu.Lock()
defer e.mu.Unlock()
doc, exists := e.memDocs[docID]
if !exists {
return nil, ErrDocumentNotFound
}
audit, err := e.sm.TransitionApproverReject(doc, actorID, actorRole, actorName, rejectReason, rollbackTo)
if err != nil {
return nil, err
}
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentRejection, rejectReason)
e.memAudits[docID] = append(e.memAudits[docID], audit)
e.syncDocAndAuditToDB(ctx, doc, audit)
return doc, nil
}
// GetDocument 获取文档实体
func (e *GovernanceEngine) GetDocument(ctx context.Context, docID string) (*DocumentRecord, error) {
e.mu.RLock()
defer e.mu.RUnlock()
doc, exists := e.memDocs[docID]
if !exists {
return nil, ErrDocumentNotFound
}
return doc, nil
}
// ListAuditLogs 获取文档审计日志
func (e *GovernanceEngine) ListAuditLogs(ctx context.Context, docID string) ([]*AuditLog, error) {
e.mu.RLock()
defer e.mu.RUnlock()
logs := e.memAudits[docID]
res := make([]*AuditLog, len(logs))
copy(res, logs)
return res, 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
}
func (e *GovernanceEngine) syncDocAndAuditToDB(ctx context.Context, doc *DocumentRecord, audit *AuditLog) {
if e.pool == nil {
return
}
_, _ = e.pool.Exec(ctx, `
UPDATE document_records
SET current_state = $1, is_locked = $2,
last_rejected_reason = $3, last_rejected_by_role = $4, last_rejected_by_name = $5, last_rejected_at = $6,
approved_at = $7, updated_at = $8
WHERE doc_id = $9
`, string(doc.CurrentState), doc.IsLocked,
doc.LastRejectedReason, doc.LastRejectedByRole, doc.LastRejectedByName, doc.LastRejectedAt,
doc.ApprovedAt, doc.UpdatedAt, doc.DocID)
if audit != nil {
detJSON, _ := json.Marshal(audit.Details)
_, _ = 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)
}
}