From 4a7b8cc26f9b3a036bc1d0d908e00bb2a94bab09 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 15:35:07 +0000 Subject: [PATCH] feat(workflow): reopen a completed stage without pausing the later one Selected documents return to drafting, and advance asks the creator to acknowledge an open recheck mark. Co-authored-by: Cursor --- internal/docgov/engine.go | 23 +++ internal/docgov/reopen_test.go | 24 +++ internal/docgov/state_machine.go | 24 +++ internal/projectinstance/errors.go | 2 + internal/projectinstance/service.go | 13 ++ internal/projectinstance/stage_reopen_test.go | 139 ++++++++++++++++++ internal/projectinstance/stage_service.go | 52 +++++-- internal/projectinstance/stage_store.go | 104 ++++++++++++- internal/projectinstance/store.go | 69 ++++++++- internal/projectinstance/types.go | 8 +- 10 files changed, 437 insertions(+), 21 deletions(-) create mode 100644 internal/docgov/reopen_test.go create mode 100644 internal/projectinstance/stage_reopen_test.go diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go index f0220df7..ab5ad78c 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -991,6 +991,29 @@ func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, act return doc, nil } +// ReopenForRevision moves an approved document back to drafting without recording a rejection. +func (e *GovernanceEngine) ReopenForRevision(ctx context.Context, docID string, actorID int64, actorName, reason string) (*DocumentRecord, 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 + } + } + audit, err := e.sm.TransitionReopenForRevision(doc, actorID, actorName, reason) + if err != nil { + return nil, err + } + e.memAudits[docID] = append(e.memAudits[docID], audit) + if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil { + return nil, err + } + return doc, nil +} + // GetDocument 获取文档实体 (优先查内存,未命中查 DB) func (e *GovernanceEngine) GetDocument(ctx context.Context, docID string) (*DocumentRecord, error) { e.mu.Lock() diff --git a/internal/docgov/reopen_test.go b/internal/docgov/reopen_test.go new file mode 100644 index 00000000..15a6dfc2 --- /dev/null +++ b/internal/docgov/reopen_test.go @@ -0,0 +1,24 @@ +package docgov + +import ( + "errors" + "testing" +) + +func TestTransitionReopenForRevision_IsNotARejection(t *testing.T) { + sm := NewStateMachine() + doc := &DocumentRecord{DocID: "doc_reopen", CurrentState: StateApproved, IsLocked: true, Config: DefaultDocumentConfig()} + audit, err := sm.TransitionReopenForRevision(doc, 4, "creator", "新材料") + if err != nil { + t.Fatal(err) + } + if doc.CurrentState != StateDrafting || doc.IsLocked { + t.Fatalf("state %s locked %v", doc.CurrentState, doc.IsLocked) + } + if audit.Action != "reopen_for_revision" || audit.ActorRole != RoleSystem { + t.Fatalf("audit %+v", audit) + } + if _, err := sm.TransitionReopenForRevision(doc, 4, "creator", "again"); !errors.Is(err, ErrInvalidStateTransition) { + t.Fatalf("drafting reopen err %v", err) + } +} diff --git a/internal/docgov/state_machine.go b/internal/docgov/state_machine.go index 8dd09008..5f97cfad 100644 --- a/internal/docgov/state_machine.go +++ b/internal/docgov/state_machine.go @@ -283,3 +283,27 @@ func (sm *StateMachine) TransitionApproverReject(doc *DocumentRecord, actorID in CreatedAt: now, }, nil } + +// TransitionReopenForRevision unlocks an approved document for another draft. +// It is not a rejection: the previous approval stays in the audit as a reopen. +func (sm *StateMachine) TransitionReopenForRevision(doc *DocumentRecord, actorID int64, actorName, reason string) (*AuditLog, error) { + if doc == nil { + return nil, ErrDocumentNotFound + } + if doc.CurrentState != StateApproved { + return nil, fmt.Errorf("%w: 只有已批准文档可以再打开编制,当前为【%s】", ErrInvalidStateTransition, doc.CurrentState) + } + if strings.TrimSpace(reason) == "" { + return nil, fmt.Errorf("%w: 再打开必须填写原因", ErrInvalidStateTransition) + } + from := doc.CurrentState + doc.CurrentState = StateDrafting + doc.IsLocked = false + now := time.Now() + doc.UpdatedAt = now + return &AuditLog{ + DocID: doc.DocID, Action: "reopen_for_revision", ActorID: actorID, ActorName: actorName, + ActorRole: RoleSystem, FromState: from, ToState: StateDrafting, + Details: map[string]any{"reason": reason, "timestamp": now}, CreatedAt: now, + }, nil +} diff --git a/internal/projectinstance/errors.go b/internal/projectinstance/errors.go index c82981b1..a81bea6a 100644 --- a/internal/projectinstance/errors.go +++ b/internal/projectinstance/errors.go @@ -23,6 +23,8 @@ var ( ErrInstanceNotFound = errors.New("workflow instance not found") ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved") ErrStageNotActive = errors.New("stage is not the active stage") + ErrStageNotCompleted = errors.New("stage is not completed") + ErrRecheckAckRequired = errors.New("recheck acknowledgement required") ErrInvalidStage = errors.New("invalid stage key for instance") ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch") ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate") diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index 1fbd2942..7eeae54a 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -28,6 +28,19 @@ type Service struct { authorOnlyPolicy AuthorOnlyPolicySource projector DocumentDutyProjector busyChecker DocTaskBusyChecker + recheck RecheckGate +} + +// RecheckGate is the soft hold on a completed stage. Acknowledgement is not clearance. +type RecheckGate interface { + StageNeedsAck(ctx context.Context, projectID int64, stageKey string) (bool, error) + Acknowledge(ctx context.Context, projectID int64, stageKey string, userID int64) error +} + +func (s *Service) SetRecheckGate(g RecheckGate) { + if s != nil { + s.recheck = g + } } func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store, docs DocumentReadinessChecker) *Service { diff --git a/internal/projectinstance/stage_reopen_test.go b/internal/projectinstance/stage_reopen_test.go new file mode 100644 index 00000000..7ed6fd02 --- /dev/null +++ b/internal/projectinstance/stage_reopen_test.go @@ -0,0 +1,139 @@ +package projectinstance + +import ( + "context" + "errors" + "testing" +) + +type ackGate struct { + need bool + acked int +} + +func (g *ackGate) StageNeedsAck(context.Context, int64, string) (bool, error) { + return g.need, nil +} + +func (g *ackGate) Acknowledge(context.Context, int64, string, int64) error { + g.acked++ + return nil +} + +func TestReopen_KeepsLaterStageActive(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolverMultiStage(), nil, &stubDocChecker{states: map[string]string{ + "intake": "approved", "feasibility_reg": "approved", + }}) + plan := validPlan() + plan.Stages = append(plan.Stages, StagePlan{ + StageKey: "V0.5_feasibility", Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "feasibility_reg", Enabled: true, + Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + }}, + }) + if _, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 42, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + activated, err := svc.Activate(ctx, 42, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("activate: %v", err) + } + advanced, err := svc.AdvanceStage(ctx, activated.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: activated.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("advance: %v", err) + } + if err := svc.ReopenStage(ctx, activated.Instance.ID, "V0.1_triage", []string{"intake"}, 1); err != nil { + t.Fatalf("reopen: %v", err) + } + reopened, err := svc.GetInstanceDetail(ctx, activated.Instance.ID) + if err != nil { + t.Fatal(err) + } + var triage, feasibility string + for _, st := range reopened.Stages { + switch st.StageKey { + case "V0.1_triage": + triage = st.Status + case "V0.5_feasibility": + feasibility = st.Status + } + } + if triage != StageActive || feasibility != StageActive { + t.Fatalf("triage %s feasibility %s", triage, feasibility) + } + again, err := svc.AdvanceStage(ctx, activated.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: reopened.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("second advance: %v", err) + } + if again.NextStageKey != "" && again.NextStageKey != "V0.5_feasibility" { + t.Fatalf("unexpected next %s", again.NextStageKey) + } + detail, err := svc.GetInstanceDetail(ctx, activated.Instance.ID) + if err != nil { + t.Fatal(err) + } + for _, st := range detail.Stages { + if st.StageKey == "V0.5_feasibility" && st.Status != StageActive { + t.Fatalf("downstream became %s", st.Status) + } + if st.StageKey == "V0.1_triage" && st.Status != StageCompleted { + t.Fatalf("reopened stage became %s", st.Status) + } + } + _ = advanced +} + +func TestAdvance_RecheckAckDoesNotClearByItself(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 9, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) + gate := &ackGate{need: true} + svc.SetRecheckGate(gate) + if _, err := svc.SaveWorkflowPlan(ctx, 9, 1, true, SavePlanInput{Plan: validPlan(), ExpectedPlanRevision: 0}); err != nil { + t.Fatal(err) + } + preview, err := svc.ActivationPreview(ctx, 9, 1, true) + if err != nil { + t.Fatal(err) + } + result, err := svc.Activate(ctx, 9, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatal(err) + } + _, err = svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, + }) + if !errors.Is(err, ErrRecheckAckRequired) { + t.Fatalf("expected ack required, got %v", err) + } + if gate.acked != 0 { + t.Fatal("ack recorded before confirm") + } + if _, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, AcknowledgeRecheck: true, + }); err != nil { + t.Fatal(err) + } + if gate.acked != 1 { + t.Fatalf("acked %d", gate.acked) + } +} diff --git a/internal/projectinstance/stage_service.go b/internal/projectinstance/stage_service.go index 82b6317b..40c98bc6 100644 --- a/internal/projectinstance/stage_service.go +++ b/internal/projectinstance/stage_service.go @@ -50,16 +50,31 @@ func (s *Service) GetStageReadiness(ctx context.Context, instanceID int64, stage if stageDef != nil && stageDef.Name != "" { stageName = stageDef.Name } + stageIsActive := false + for _, st := range detail.Stages { + if st.StageKey == stageKey && st.Status == StageActive { + stageIsActive = true + break + } + } + needsAck := false + if s.recheck != nil { + needsAck, err = s.recheck.StageNeedsAck(ctx, detail.Instance.ProjectID, stageKey) + if err != nil { + return nil, err + } + } return &StageReadiness{ - InstanceID: instanceID, - StageKey: stageKey, - StageName: stageName, - Ready: ready, - CanAdvance: isCreator && ready && detail.ActiveStageKey == stageKey, - BlockingIssues: issues, - DocTasks: tasks, - ConfigRevision: detail.Instance.ConfigRevision, + InstanceID: instanceID, + StageKey: stageKey, + StageName: stageName, + Ready: ready, + CanAdvance: isCreator && ready && stageIsActive, + RequiresRecheckAck: needsAck, + BlockingIssues: issues, + DocTasks: tasks, + ConfigRevision: detail.Instance.ConfigRevision, }, nil } @@ -80,11 +95,18 @@ func (s *Service) AdvanceStage(ctx context.Context, instanceID int64, userID int if in.ExpectedConfigRevision != readiness.ConfigRevision { return nil, ErrInstanceRevisionConflict } - before, err := s.store.GetInstanceByID(ctx, instanceID) if err != nil { return nil, err } + if readiness.RequiresRecheckAck && !in.AcknowledgeRecheck { + return nil, ErrRecheckAckRequired + } + if readiness.RequiresRecheckAck && s.recheck != nil { + if err := s.recheck.Acknowledge(ctx, before.Instance.ProjectID, in.StageKey, userID); err != nil { + return nil, err + } + } completedStage := before.ActiveStageKey var completedStageInstanceID int64 for _, st := range before.Stages { @@ -108,3 +130,15 @@ func (s *Service) AdvanceStage(ctx context.Context, instanceID int64, userID int InstanceDone: after.Instance.Status == InstanceCompleted, }, nil } + +// ReopenStage opens a completed stage again. Other stages stay as they are. +func (s *Service) ReopenStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) error { + if !s.Available() { + return ErrNotAvailable + } + if stageKey == "" || len(docTaskKeys) == 0 { + return ErrInvalidStage + } + _, err := s.store.ReopenStage(ctx, instanceID, stageKey, docTaskKeys, operatorID) + return err +} diff --git a/internal/projectinstance/stage_store.go b/internal/projectinstance/stage_store.go index 70b7dbad..d0540362 100644 --- a/internal/projectinstance/stage_store.go +++ b/internal/projectinstance/stage_store.go @@ -118,17 +118,35 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s if err != nil { return nil, err } + var otherActive int + if err = tx.QueryRow(ctx, ` + SELECT COUNT(*) FROM project_stage_instances + WHERE workflow_instance_id = $1 AND enabled = true AND status = 'active' + `, instanceID).Scan(&otherActive); err != nil { + return nil, err + } var nextStageID int64 var nextStageKey string var projectID int64 _ = tx.QueryRow(ctx, `SELECT project_id FROM project_workflow_instances WHERE id = $1`, instanceID).Scan(&projectID) - err = tx.QueryRow(ctx, ` - SELECT id, stage_key FROM project_stage_instances - WHERE workflow_instance_id = $1 AND enabled = true AND status = 'pending' - ORDER BY position ASC, id ASC LIMIT 1 - `, instanceID).Scan(&nextStageID, &nextStageKey) - if errors.Is(err, pgx.ErrNoRows) { + err = pgx.ErrNoRows + if otherActive == 0 { + err = tx.QueryRow(ctx, ` + SELECT id, stage_key FROM project_stage_instances + WHERE workflow_instance_id = $1 AND enabled = true AND status = 'pending' + ORDER BY position ASC, id ASC LIMIT 1 + `, instanceID).Scan(&nextStageID, &nextStageKey) + } + if otherActive > 0 { + _, err = tx.Exec(ctx, ` + UPDATE project_workflow_instances SET status = 'active', config_revision = config_revision + 1, updated_at = $2 + WHERE id = $1 + `, instanceID, now) + if err != nil { + return nil, err + } + } else if errors.Is(err, pgx.ErrNoRows) { _, err = tx.Exec(ctx, ` UPDATE project_workflow_instances SET status = 'completed', config_revision = config_revision + 1, completed_at = $2, completed_by = $3, updated_at = $2 @@ -201,6 +219,80 @@ func (s *pgStore) MarkDocTaskApproved(ctx context.Context, projectID int64, task return err } +func (s *pgStore) ReopenStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) (*InstanceDetail, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var instStatus string + err = tx.QueryRow(ctx, ` + SELECT status FROM project_workflow_instances WHERE id = $1 FOR UPDATE + `, instanceID).Scan(&instStatus) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInstanceNotFound + } + if err != nil { + return nil, err + } + if instStatus != InstanceActive && instStatus != InstanceCompleted { + return nil, fmt.Errorf("%w: instance status %s", ErrInvalidStage, instStatus) + } + var stageID int64 + var stageStatus string + err = tx.QueryRow(ctx, ` + SELECT id, status FROM project_stage_instances + WHERE workflow_instance_id = $1 AND stage_key = $2 AND enabled = true + `, instanceID, stageKey).Scan(&stageID, &stageStatus) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidStage + } + if err != nil { + return nil, err + } + if stageStatus != StageCompleted { + return nil, ErrStageNotCompleted + } + now := time.Now() + if _, err = tx.Exec(ctx, ` + UPDATE project_stage_instances SET status = 'active', updated_at = $2 WHERE id = $1 + `, stageID, now); err != nil { + return nil, err + } + if len(docTaskKeys) > 0 { + if _, err = tx.Exec(ctx, ` + UPDATE project_doc_task_instances + SET status = 'in_progress', updated_at = $3 + WHERE stage_instance_id = $1 AND task_key = ANY($2) AND status = 'approved' + `, stageID, docTaskKeys, now); err != nil { + return nil, err + } + } + if _, err = tx.Exec(ctx, ` + UPDATE project_workflow_instances + SET status = 'active', config_revision = config_revision + 1, updated_at = $2, + completed_at = NULL, completed_by = NULL + WHERE id = $1 + `, instanceID, now); err != nil { + return nil, err + } + _ = operatorID + if err := tx.Commit(ctx); err != nil { + return nil, err + } + row := s.pool.QueryRow(ctx, ` + SELECT id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, + config_snapshot, activated_by, activated_at, created_by, created_at, updated_at + FROM project_workflow_instances WHERE id = $1 + `, instanceID) + inst, err := scanInstance(row) + if err != nil { + return nil, err + } + return s.loadInstanceDetail(ctx, inst) +} + type dbExec interface { Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) } diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index c532211e..3918a361 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -57,6 +57,8 @@ type Store interface { GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error) AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int, operatorID int64) (*InstanceDetail, error) + // ReopenStage sets a completed stage back to active without changing other stages. + ReopenStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) (*InstanceDetail, error) MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error // UpdateLiveDocTaskDuties 更新 live 实例 config_snapshot 与 document_duty_assignments(单任务)。 UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, taskKey string, plan WorkflowPlan, assignedBy int64) (*WorkflowInstance, error) @@ -1148,16 +1150,27 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st return nil, ErrInvalidStage } records[activeIdx].status = StageCompleted - nextIdx := -1 + anotherActive := false for i, rec := range records { - if rec.enabled && rec.status == StagePending { - nextIdx = i + if i != activeIdx && rec.enabled && rec.status == StageActive { + anotherActive = true break } } + nextIdx := -1 + if !anotherActive { + for i, rec := range records { + if rec.enabled && rec.status == StagePending { + nextIdx = i + break + } + } + } inst.ConfigRevision++ inst.UpdatedAt = time.Now() - if nextIdx < 0 { + if anotherActive { + inst.Status = InstanceActive + } else if nextIdx < 0 { inst.Status = InstanceCompleted if pm := m.meta[inst.ProjectID]; pm.lifecycle != "" { pm.lifecycle = LifecyclePlanning @@ -1173,6 +1186,54 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st return m.instanceDetailLocked(©Inst), nil } +func (m *memStore) ReopenStage(_ context.Context, instanceID int64, stageKey string, docTaskKeys []string, _ int64) (*InstanceDetail, error) { + m.mu.Lock() + defer m.mu.Unlock() + var inst *WorkflowInstance + for _, live := range m.live { + if live.ID == instanceID { + inst = live + break + } + } + if inst == nil { + return nil, ErrInstanceNotFound + } + records := m.stages[instanceID] + idx := -1 + for i, rec := range records { + if rec.stageKey == stageKey { + idx = i + break + } + } + if idx < 0 { + return nil, ErrInvalidStage + } + if records[idx].status != StageCompleted { + return nil, ErrStageNotCompleted + } + records[idx].status = StageActive + wanted := map[string]bool{} + for _, key := range docTaskKeys { + wanted[key] = true + } + for j := range records[idx].tasks { + if wanted[records[idx].tasks[j].taskKey] { + records[idx].tasks[j].status = "in_progress" + } + } + if inst.Status == InstanceCompleted { + inst.Status = InstanceActive + } + inst.ConfigRevision++ + inst.UpdatedAt = time.Now() + m.stages[instanceID] = records + copyInst := *inst + m.live[inst.ProjectID] = ©Inst + return m.instanceDetailLocked(©Inst), nil +} + func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskKey string) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/projectinstance/types.go b/internal/projectinstance/types.go index ae84dab8..128f9089 100644 --- a/internal/projectinstance/types.go +++ b/internal/projectinstance/types.go @@ -240,8 +240,9 @@ type StageReadiness struct { InstanceID int64 `json:"instance_id"` StageKey string `json:"stage_key"` StageName string `json:"stage_name"` - Ready bool `json:"ready"` - CanAdvance bool `json:"can_advance"` + Ready bool `json:"ready"` + CanAdvance bool `json:"can_advance"` + RequiresRecheckAck bool `json:"requires_recheck_ack"` BlockingIssues []GateIssue `json:"blocking_issues"` DocTasks []DocTaskReadiness `json:"doc_tasks"` ConfigRevision int `json:"config_revision"` @@ -251,6 +252,9 @@ type StageReadiness struct { type AdvanceStageInput struct { StageKey string `json:"stage_key"` ExpectedConfigRevision int `json:"expected_config_revision"` + // AcknowledgeRecheck records that the creator saw open recheck marks. + // It does not clear those marks. + AcknowledgeRecheck bool `json:"acknowledge_recheck"` } // AdvanceStageResult 阶段推进结果。