feat(projectinstance): refresh stage readiness after document approval.
Sync doc task status on approve and evaluate whether to emit stage advance readiness; chain docgov hooks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
10fa91de61
commit
32b88beee0
@@ -30,7 +30,8 @@ type GovernanceEngine struct {
|
||||
memComms map[string][]*DocumentComment
|
||||
memProps map[string]*ChangeProposal
|
||||
memAudits map[string][]*AuditLog
|
||||
onApprovedHook func(ctx context.Context, doc *DocumentRecord)
|
||||
onApprovedHook func(ctx context.Context, doc *DocumentRecord)
|
||||
onApprovedHooks []func(ctx context.Context, doc *DocumentRecord)
|
||||
}
|
||||
|
||||
// SetOnApprovedHook 注册文档终审批准时的回调钩子
|
||||
@@ -40,6 +41,34 @@ func (e *GovernanceEngine) SetOnApprovedHook(hook func(ctx context.Context, doc
|
||||
e.onApprovedHook = hook
|
||||
}
|
||||
|
||||
// AddOnApprovedHook 追加文档终审批准回调(与 SetOnApprovedHook 并存)。
|
||||
func (e *GovernanceEngine) AddOnApprovedHook(hook func(ctx context.Context, doc *DocumentRecord)) {
|
||||
if hook == nil {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.onApprovedHooks = append(e.onApprovedHooks, hook)
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) runApprovedHooks(ctx context.Context, doc *DocumentRecord) {
|
||||
var primary func(context.Context, *DocumentRecord)
|
||||
var extra []func(context.Context, *DocumentRecord)
|
||||
e.mu.Lock()
|
||||
primary = e.onApprovedHook
|
||||
extra = append([]func(context.Context, *DocumentRecord){}, e.onApprovedHooks...)
|
||||
e.mu.Unlock()
|
||||
|
||||
if primary != nil {
|
||||
primary(ctx, doc)
|
||||
}
|
||||
for _, hook := range extra {
|
||||
if hook != nil {
|
||||
hook(ctx, doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewGovernanceEngine 初始化治理引擎
|
||||
func NewGovernanceEngine(pool *pgxpool.Pool) *GovernanceEngine {
|
||||
return &GovernanceEngine{
|
||||
@@ -821,12 +850,9 @@ func (e *GovernanceEngine) ApproverApprove(ctx context.Context, docID string, ac
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.syncDocAndAuditToDB(ctx, doc, audit)
|
||||
|
||||
if e.onApprovedHook != nil {
|
||||
hook := e.onApprovedHook
|
||||
e.mu.Unlock()
|
||||
hook(ctx, doc)
|
||||
e.mu.Lock()
|
||||
}
|
||||
e.mu.Unlock()
|
||||
e.runApprovedHooks(ctx, doc)
|
||||
e.mu.Lock()
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package projectinstance
|
||||
|
||||
import "context"
|
||||
|
||||
// DocumentApprovedResult 文档批准后阶段就绪评估结果。
|
||||
type DocumentApprovedResult struct {
|
||||
StageReady bool
|
||||
InstanceID int64
|
||||
StageInstanceID int64
|
||||
StageKey string
|
||||
StageName string
|
||||
ConfigRevision int
|
||||
RequiredDocCount int
|
||||
ApprovedDocCount int
|
||||
}
|
||||
|
||||
// HandleDocumentApproved 同步文档任务状态并评估是否首次满足阶段推进条件。
|
||||
func (s *Service) HandleDocumentApproved(ctx context.Context, projectID int64, taskKey string) (*DocumentApprovedResult, error) {
|
||||
out := &DocumentApprovedResult{}
|
||||
if s == nil || !s.Available() || projectID <= 0 || taskKey == "" {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if err := s.store.MarkDocTaskApproved(ctx, projectID, taskKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||||
if err != nil || detail == nil || detail.ActiveStageKey == "" {
|
||||
return out, err
|
||||
}
|
||||
|
||||
readiness, err := s.GetStageReadiness(ctx, live.ID, detail.ActiveStageKey, 0, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !readiness.Ready {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var stageInstID int64
|
||||
for _, st := range detail.Stages {
|
||||
if st.StageKey == detail.ActiveStageKey {
|
||||
stageInstID = st.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if stageInstID <= 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
required := 0
|
||||
approved := 0
|
||||
for _, dt := range readiness.DocTasks {
|
||||
if !dt.Enabled || !dt.Required {
|
||||
continue
|
||||
}
|
||||
required++
|
||||
if dt.Approved {
|
||||
approved++
|
||||
}
|
||||
}
|
||||
|
||||
out.StageReady = true
|
||||
out.InstanceID = live.ID
|
||||
out.StageInstanceID = stageInstID
|
||||
out.StageKey = readiness.StageKey
|
||||
out.StageName = readiness.StageName
|
||||
out.ConfigRevision = readiness.ConfigRevision
|
||||
out.RequiredDocCount = required
|
||||
out.ApprovedDocCount = approved
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleDocumentApproved_StageReady(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 1, LifecycleActive, "medical_device_development", 1, 1)
|
||||
|
||||
svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}})
|
||||
plan := WorkflowPlan{
|
||||
WorkflowDefinitionKey: "medical_device_development",
|
||||
WorkflowVersion: 1,
|
||||
Stages: []StagePlan{{
|
||||
StageKey: "biz_intake", Enabled: true,
|
||||
DocTasks: []DocTaskPlan{{
|
||||
TaskKey: "intake", Enabled: true,
|
||||
Duties: DocDuty{AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 4},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
inst := &WorkflowInstance{
|
||||
ID: 1, ProjectID: 1, WorkflowVersionID: 10, Status: InstanceActive,
|
||||
ConfigRevision: 1, PlanHash: "h", ConfigSnapshot: plan, InstanceNo: 1,
|
||||
}
|
||||
activated, err := store.ActivateInstance(ctx, 1, inst, "medical_device_development", 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("activate: %v", err)
|
||||
}
|
||||
|
||||
result, err := svc.HandleDocumentApproved(ctx, 1, "intake")
|
||||
if err != nil {
|
||||
t.Fatalf("handle approved: %v", err)
|
||||
}
|
||||
if result == nil || !result.StageReady {
|
||||
t.Fatalf("expected stage ready, got %+v", result)
|
||||
}
|
||||
if result.InstanceID != activated.ID || result.StageKey != "biz_intake" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"chat-agent-system/internal/db"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/notification"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
@@ -140,6 +141,34 @@ func TestPG_ActivationAndStageAdvance(t *testing.T) {
|
||||
t.Fatalf("approve doc: %v", err)
|
||||
}
|
||||
|
||||
approvedResult, err := svc.HandleDocumentApproved(ctx, projectID, "intake")
|
||||
if err != nil {
|
||||
t.Fatalf("handle document approved: %v", err)
|
||||
}
|
||||
if approvedResult == nil || !approvedResult.StageReady {
|
||||
t.Fatalf("expected stage ready after approval, got %+v", approvedResult)
|
||||
}
|
||||
|
||||
notifySvc := notification.NewService(pool)
|
||||
if notifySvc.Available() {
|
||||
created, emitErr := notifySvc.EmitStageReadyToAdvance(ctx, 2, notification.StageReadyPayload{
|
||||
ProjectID: projectID,
|
||||
WorkflowInstanceID: approvedResult.InstanceID,
|
||||
StageInstanceID: approvedResult.StageInstanceID,
|
||||
StageKey: approvedResult.StageKey,
|
||||
StageName: approvedResult.StageName,
|
||||
ConfigRevision: approvedResult.ConfigRevision,
|
||||
RequiredDocCount: approvedResult.RequiredDocCount,
|
||||
ApprovedDocCount: approvedResult.ApprovedDocCount,
|
||||
})
|
||||
if emitErr != nil {
|
||||
t.Fatalf("emit stage ready notification: %v", emitErr)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("expected stage ready notification to be created")
|
||||
}
|
||||
}
|
||||
|
||||
readiness, err := svc.GetStageReadiness(ctx, activated.Instance.ID, "biz_intake", 2, true)
|
||||
if err != nil {
|
||||
t.Fatalf("readiness: %v", err)
|
||||
|
||||
@@ -172,3 +172,21 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s
|
||||
}
|
||||
return s.loadInstanceDetail(ctx, inst)
|
||||
}
|
||||
|
||||
func (s *pgStore) MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error {
|
||||
if projectID <= 0 || taskKey == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE project_doc_task_instances dti
|
||||
SET status = 'approved', updated_at = now()
|
||||
FROM project_stage_instances psi
|
||||
JOIN project_workflow_instances pwi ON pwi.id = psi.workflow_instance_id
|
||||
WHERE dti.stage_instance_id = psi.id
|
||||
AND pwi.project_id = $1
|
||||
AND dti.task_key = $2
|
||||
AND dti.enabled = true
|
||||
AND dti.status <> 'skipped'
|
||||
`, projectID, taskKey)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ type Store interface {
|
||||
|
||||
GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error)
|
||||
AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int) (*InstanceDetail, error)
|
||||
MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error
|
||||
}
|
||||
|
||||
// NewStore 构建 PG 或内存 Store。
|
||||
@@ -672,6 +673,25 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st
|
||||
return m.instanceDetailLocked(©Inst), nil
|
||||
}
|
||||
|
||||
func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskKey string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
live := m.live[projectID]
|
||||
if live == nil {
|
||||
return nil
|
||||
}
|
||||
records := m.stages[live.ID]
|
||||
for i := range records {
|
||||
for j := range records[i].tasks {
|
||||
if records[i].tasks[j].taskKey == taskKey && records[i].tasks[j].enabled {
|
||||
records[i].tasks[j].status = "approved"
|
||||
}
|
||||
}
|
||||
}
|
||||
m.stages[live.ID] = records
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) SaveActivationToken(_ context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, _ int64, expiresAt time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user