merge: candidate bizdev-stage-gating-narrative-2026-09-28 into dev
This commit is contained in:
@@ -93,7 +93,8 @@ func (s *Service) CheckDocTaskAccess(
|
||||
}
|
||||
}
|
||||
|
||||
if stageKey != "" && stageKey != detail.ActiveStageKey {
|
||||
// narrative 快照不按活动阶段拦截编制;职责、禁用和实例关闭检查仍在后面。
|
||||
if stageKey != "" && stageKey != detail.ActiveStageKey && !gov.ConfigSnapshot.NarrativeStageGating() {
|
||||
if action != DocAccessActionView {
|
||||
return DocAccessDecision{
|
||||
Allowed: false,
|
||||
|
||||
@@ -68,6 +68,7 @@ func (s *Service) UpdateLivePlan(ctx context.Context, projectID int64, userID in
|
||||
if err := s.rejectBusyDocTaskChanges(ctx, projectID, live.ConfigSnapshot, plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stampPlanStageGating(&plan, version)
|
||||
|
||||
updated, err := s.store.UpdateLivePlan(ctx, projectID, plan, live.ConfigRevision, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
func TestNarrativeGating_PendingStageDocsRunnable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 880, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, twoStageResolver(workflowdef.StageGatingNarrative), nil, nil)
|
||||
activateTwoStage(t, svc, 880)
|
||||
forcePartialDuty(store, 880, "partial_doc")
|
||||
|
||||
access, err := svc.ResolveStageAccess(ctx, 880, 99, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if access.ActiveStageKey != "V0.1_triage" {
|
||||
t.Fatalf("active=%s", access.ActiveStageKey)
|
||||
}
|
||||
if !access.TaskKeyAllowed("research") {
|
||||
t.Fatalf("narrative must allow pending-stage research, %+v", access.AllowedTaskKeys)
|
||||
}
|
||||
if !access.StageKeyAllowed("V0.5_feasibility") {
|
||||
t.Fatal("narrative must open later stage keys")
|
||||
}
|
||||
if access.TaskKeyAllowed("disabled_doc") {
|
||||
t.Fatal("disabled docs stay out of AllowedTaskKeys")
|
||||
}
|
||||
if access.TaskKeyAllowed("partial_doc") {
|
||||
t.Fatal("partial duty stays out of AllowedTaskKeys")
|
||||
}
|
||||
|
||||
run := svc.CheckDocTaskAccess(ctx, 880, 99, "research", DocAccessActionRun, false)
|
||||
if !run.Allowed {
|
||||
t.Fatalf("narrative run: %+v", run)
|
||||
}
|
||||
submit := svc.CheckDocTaskAccess(ctx, 880, 99, "research", DocAccessActionSubmit, false)
|
||||
if submit.Allowed || submit.Code != AccessCodeForbiddenDuty {
|
||||
t.Fatalf("narrative must keep duty on submit: %+v", submit)
|
||||
}
|
||||
authorSubmit := svc.CheckDocTaskAccess(ctx, 880, 1, "research", DocAccessActionSubmit, false)
|
||||
if !authorSubmit.Allowed {
|
||||
t.Fatalf("author submit: %+v", authorSubmit)
|
||||
}
|
||||
disabled := svc.CheckDocTaskAccess(ctx, 880, 1, "disabled_doc", DocAccessActionRun, true)
|
||||
if disabled.Allowed || disabled.Code != AccessCodeDocTaskDisabled {
|
||||
t.Fatalf("disabled: %+v", disabled)
|
||||
}
|
||||
partial := svc.CheckDocTaskAccess(ctx, 880, 1, "partial_doc", DocAccessActionEdit, true)
|
||||
if partial.Allowed || partial.Code != AccessCodeForbiddenDuty {
|
||||
t.Fatalf("partial: %+v", partial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictGating_PendingStageDocsDenied(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 881, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, twoStageResolver(""), nil, nil)
|
||||
activateTwoStage(t, svc, 881)
|
||||
|
||||
access, err := svc.ResolveStageAccess(ctx, 881, 99, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if access.TaskKeyAllowed("research") {
|
||||
t.Fatal("strict must not run pending-stage research")
|
||||
}
|
||||
if !access.TaskKeyVisible("research") {
|
||||
t.Fatal("strict view exemption stays")
|
||||
}
|
||||
decision := svc.CheckDocTaskAccess(ctx, 881, 1, "research", DocAccessActionRun, true)
|
||||
if decision.Allowed || decision.Code != AccessCodeNotCurrentStage {
|
||||
t.Fatalf("strict run: %+v", decision)
|
||||
}
|
||||
view := svc.CheckDocTaskAccess(ctx, 881, 1, "research", DocAccessActionView, true)
|
||||
if !view.Allowed {
|
||||
t.Fatalf("strict view: %+v", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStampPlanStageGating_OmittedForStrictHash(t *testing.T) {
|
||||
version := &workflowdef.Version{Definition: workflowdef.VersionDefinition{}}
|
||||
plan := WorkflowPlan{WorkflowDefinitionKey: "main", WorkflowVersion: 1}
|
||||
stampPlanStageGating(&plan, version)
|
||||
if plan.StageGating != "" {
|
||||
t.Fatalf("strict stamp=%q", plan.StageGating)
|
||||
}
|
||||
h1, err := computePlanHash(plan)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan.StageGating = workflowdef.StageGatingStrict
|
||||
stampPlanStageGating(&plan, version)
|
||||
h2, err := computePlanHash(plan)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h1 != h2 {
|
||||
t.Fatalf("strict hash drifted %s vs %s", h1, h2)
|
||||
}
|
||||
version.Definition.StageGating = workflowdef.StageGatingNarrative
|
||||
stampPlanStageGating(&plan, version)
|
||||
if plan.StageGating != workflowdef.StageGatingNarrative {
|
||||
t.Fatalf("narrative stamp=%q", plan.StageGating)
|
||||
}
|
||||
}
|
||||
|
||||
func twoStageResolver(gating string) *stubVersionResolver {
|
||||
r := testResolver()
|
||||
ver := r.versions[workflowdef.OfficialKeyMain]
|
||||
ver.Definition.StageGating = gating
|
||||
ver.Definition.Stages = []workflowdef.StageDef{
|
||||
{
|
||||
Key: "V0.1_triage", Name: "V0.1 需求摸底", Position: 10,
|
||||
DocTasks: []workflowdef.DocTaskDef{{
|
||||
Key: "intake", Title: "用户初始需求收集表", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true,
|
||||
OutputDocType: "intake", OutputDocTitle: "用户初始需求收集表",
|
||||
}},
|
||||
},
|
||||
{
|
||||
Key: "V0.5_feasibility", Name: "V0.5 方案与可行性", Position: 20,
|
||||
DocTasks: []workflowdef.DocTaskDef{
|
||||
{
|
||||
Key: "research", Title: "调研报告", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true,
|
||||
OutputDocType: "research", OutputDocTitle: "调研报告",
|
||||
},
|
||||
{
|
||||
Key: "disabled_doc", Title: "关闭文档", CreatorConfigurable: true, DefaultEnabled: false,
|
||||
OutputDocType: "disabled_doc", OutputDocTitle: "关闭文档",
|
||||
},
|
||||
{
|
||||
Key: "partial_doc", Title: "半配置", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true,
|
||||
OutputDocType: "partial_doc", OutputDocTitle: "半配置",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func activateTwoStage(t *testing.T, svc *Service, projectID int64) {
|
||||
t.Helper()
|
||||
full := DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}
|
||||
plan := WorkflowPlan{
|
||||
WorkflowDefinitionKey: workflowdef.OfficialKeyMain,
|
||||
WorkflowVersion: 1,
|
||||
Stages: []StagePlan{
|
||||
{
|
||||
StageKey: "V0.1_triage", Enabled: true,
|
||||
DocTasks: []DocTaskPlan{{TaskKey: "intake", Enabled: true, Duties: full}},
|
||||
},
|
||||
{
|
||||
StageKey: "V0.5_feasibility", Enabled: true,
|
||||
DocTasks: []DocTaskPlan{
|
||||
{TaskKey: "research", Enabled: true, Duties: full},
|
||||
{TaskKey: "disabled_doc", Enabled: false, Duties: full},
|
||||
{TaskKey: "partial_doc", Enabled: true, Duties: full},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := svc.SaveWorkflowPlan(context.Background(), projectID, 1, true, SavePlanInput{
|
||||
Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
preview, err := svc.ActivationPreview(context.Background(), projectID, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
if _, err := svc.Activate(context.Background(), projectID, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
}); err != nil {
|
||||
t.Fatalf("activate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func forcePartialDuty(store *memStore, projectID int64, taskKey string) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
inst := store.live[projectID]
|
||||
for i := range inst.ConfigSnapshot.Stages {
|
||||
for j := range inst.ConfigSnapshot.Stages[i].DocTasks {
|
||||
dt := &inst.ConfigSnapshot.Stages[i].DocTasks[j]
|
||||
if dt.TaskKey == taskKey {
|
||||
dt.Duties = DocDuty{AuthorUserID: 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ type StageAccess struct {
|
||||
//
|
||||
// GetGoverningInstanceForAccess 语义(与 GetLiveInstance/HasLiveInstance 分离):
|
||||
// - (nil, nil):从未激活(无实例或仅 planning)→ 不限制(legacy / 新项目预激活)
|
||||
// - live 系:AllowedTaskKeys 对活跃成员开放(run 语义);ViewableTaskKeys 对全部启用文档开放
|
||||
// - live 系:strict 时 AllowedTaskKeys 只含 active/completed 阶段;narrative 时含全部已启用文档。ViewableTaskKeys 始终对全部启用文档开放
|
||||
// - completed/terminated:Restricted=true 全拒(不得误判为从未激活)
|
||||
// - 存储错误:上抛,由调用方 fail-closed
|
||||
//
|
||||
@@ -54,6 +54,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
|
||||
|
||||
out.Restricted = true
|
||||
out.ActiveStageKey = detail.ActiveStageKey
|
||||
narrative := gov.ConfigSnapshot.NarrativeStageGating()
|
||||
|
||||
stageStatus := map[string]string{}
|
||||
for _, st := range detail.Stages {
|
||||
@@ -71,14 +72,16 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
|
||||
out.ViewableTaskKeys[dt.TaskKey] = true
|
||||
}
|
||||
}
|
||||
// Stage chat/history: any caller who already passed project membership may
|
||||
// see active/completed stages. Run/edit task keys are member-level below.
|
||||
switch status {
|
||||
case StageActive, StageCompleted:
|
||||
out.AllowedStageKeys[stPlan.StageKey] = true
|
||||
default:
|
||||
continue
|
||||
// narrative:阶段只标记成熟度,已启用阶段的聊天与编制都不看 pending/active。
|
||||
// strict:仅 active/completed 阶段进入 AllowedStageKeys / AllowedTaskKeys。
|
||||
if !narrative {
|
||||
switch status {
|
||||
case StageActive, StageCompleted:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.AllowedStageKeys[stPlan.StageKey] = true
|
||||
for _, dt := range stPlan.DocTasks {
|
||||
if !dt.Enabled {
|
||||
continue
|
||||
|
||||
@@ -55,10 +55,17 @@ type StagePlan struct {
|
||||
|
||||
// WorkflowPlan 项目工作流规划快照。
|
||||
type WorkflowPlan struct {
|
||||
WorkflowDefinitionKey string `json:"workflow_definition_key"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
WorkflowVersionID int64 `json:"workflow_version_id,omitempty"`
|
||||
Stages []StagePlan `json:"stages"`
|
||||
WorkflowDefinitionKey string `json:"workflow_definition_key"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
WorkflowVersionID int64 `json:"workflow_version_id,omitempty"`
|
||||
// StageGating 仅 narrative 写入快照;空值保持旧快照 hash,语义为 strict。
|
||||
StageGating string `json:"stage_gating,omitempty"`
|
||||
Stages []StagePlan `json:"stages"`
|
||||
}
|
||||
|
||||
// NarrativeStageGating 报告阶段是否只作成熟度标记。
|
||||
func (p WorkflowPlan) NarrativeStageGating() bool {
|
||||
return p.StageGating == workflowdef.StageGatingNarrative
|
||||
}
|
||||
|
||||
// WorkflowInstance 项目工作流实例。
|
||||
|
||||
@@ -158,6 +158,7 @@ func buildDefaultPlan(version *workflowdef.Version) WorkflowPlan {
|
||||
WorkflowVersionID: version.ID,
|
||||
Stages: make([]StagePlan, 0, len(version.Definition.Stages)),
|
||||
}
|
||||
stampPlanStageGating(&plan, version)
|
||||
for _, st := range version.Definition.Stages {
|
||||
sp := StagePlan{
|
||||
StageKey: st.Key,
|
||||
@@ -183,6 +184,7 @@ func applyDefinitionLabels(plan *WorkflowPlan, version *workflowdef.Version) {
|
||||
if plan == nil || version == nil {
|
||||
return
|
||||
}
|
||||
stampPlanStageGating(plan, version)
|
||||
stageDefs := make(map[string]workflowdef.StageDef, len(version.Definition.Stages))
|
||||
for _, stage := range version.Definition.Stages {
|
||||
stageDefs[stage.Key] = stage
|
||||
@@ -208,6 +210,17 @@ func applyDefinitionLabels(plan *WorkflowPlan, version *workflowdef.Version) {
|
||||
}
|
||||
}
|
||||
|
||||
// stampPlanStageGating 以已发布定义为准。空值不写入快照,避免改变 strict 工作流的 plan hash。
|
||||
func stampPlanStageGating(plan *WorkflowPlan, version *workflowdef.Version) {
|
||||
if plan == nil {
|
||||
return
|
||||
}
|
||||
plan.StageGating = ""
|
||||
if version != nil && version.Definition.StageGating == workflowdef.StageGatingNarrative {
|
||||
plan.StageGating = workflowdef.StageGatingNarrative
|
||||
}
|
||||
}
|
||||
|
||||
func previewSummary(plan WorkflowPlan, version *workflowdef.Version) PreviewSummary {
|
||||
sum := PreviewSummary{
|
||||
WorkflowVersion: plan.WorkflowVersion,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"workflow_key": "main",
|
||||
"stage_gating": "narrative",
|
||||
"product_domains": [
|
||||
"medical_device",
|
||||
"non_medical_software"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package workflowdef
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateDefinitionV2_StageGatingEnum(t *testing.T) {
|
||||
def, err := LoadManifestDefinition("main.v2.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if def.StageGating != StageGatingNarrative {
|
||||
t.Fatalf("main manifest stage_gating=%q", def.StageGating)
|
||||
}
|
||||
if res := ValidateDefinitionV2(def, nil, nil); !res.Valid {
|
||||
t.Fatalf("narrative main manifest invalid: %+v", res.Issues)
|
||||
}
|
||||
|
||||
def.StageGating = "parallel"
|
||||
res := ValidateDefinitionV2(def, nil, nil)
|
||||
if res.Valid {
|
||||
t.Fatal("expected invalid stage_gating")
|
||||
}
|
||||
found := false
|
||||
for _, issue := range res.Issues {
|
||||
if issue.Code == "STAGE_GATING" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing STAGE_GATING issue: %+v", res.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionV2_EmptyStageGatingStaysStrict(t *testing.T) {
|
||||
def, err := LoadManifestDefinition("rdev.v2.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if def.StageGating != "" {
|
||||
t.Fatalf("rdev must stay strict by omission, got %q", def.StageGating)
|
||||
}
|
||||
if res := ValidateDefinitionV2(def, nil, nil); !res.Valid {
|
||||
t.Fatalf("rdev manifest invalid: %+v", res.Issues)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ const (
|
||||
StatusPublished = "published"
|
||||
StatusRetired = "retired"
|
||||
|
||||
// StageGatingStrict 按活动阶段限制编制(缺省)。
|
||||
StageGatingStrict = "strict"
|
||||
// StageGatingNarrative 阶段只作成熟度标记,已启用文档可在任意阶段编制。
|
||||
StageGatingNarrative = "narrative"
|
||||
|
||||
ScanPending = "pending"
|
||||
ScanReady = "ready"
|
||||
ScanRejected = "rejected"
|
||||
@@ -20,20 +25,20 @@ type TemplateRef struct {
|
||||
|
||||
// DocTaskDef 阶段内文档任务定义。
|
||||
type DocTaskDef struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Required bool `json:"required"`
|
||||
Blocking bool `json:"blocking"`
|
||||
DefaultEnabled bool `json:"default_enabled"`
|
||||
CreatorConfigurable bool `json:"creator_configurable"`
|
||||
BlockingWhenEnabled bool `json:"blocking_when_enabled"`
|
||||
TemplateAssetKey string `json:"template_asset_key,omitempty"`
|
||||
TemplateVersion int `json:"template_version,omitempty"`
|
||||
Template *TemplateRef `json:"template,omitempty"`
|
||||
ExecutorKey string `json:"executor_key,omitempty"`
|
||||
QualityGateKey string `json:"quality_gate_key,omitempty"`
|
||||
OutputDocType string `json:"output_doc_type"`
|
||||
OutputDocTitle string `json:"output_doc_title"`
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Required bool `json:"required"`
|
||||
Blocking bool `json:"blocking"`
|
||||
DefaultEnabled bool `json:"default_enabled"`
|
||||
CreatorConfigurable bool `json:"creator_configurable"`
|
||||
BlockingWhenEnabled bool `json:"blocking_when_enabled"`
|
||||
TemplateAssetKey string `json:"template_asset_key,omitempty"`
|
||||
TemplateVersion int `json:"template_version,omitempty"`
|
||||
Template *TemplateRef `json:"template,omitempty"`
|
||||
ExecutorKey string `json:"executor_key,omitempty"`
|
||||
QualityGateKey string `json:"quality_gate_key,omitempty"`
|
||||
OutputDocType string `json:"output_doc_type"`
|
||||
OutputDocTitle string `json:"output_doc_title"`
|
||||
}
|
||||
|
||||
// StageDef 线性阶段定义。
|
||||
@@ -48,13 +53,15 @@ type StageDef struct {
|
||||
|
||||
// VersionDefinition 工作流版本完整快照(JSONB)。
|
||||
type VersionDefinition struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
WorkflowKey string `json:"workflow_key,omitempty"`
|
||||
ProductDomains []string `json:"product_domains,omitempty"`
|
||||
RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"`
|
||||
Stages []StageDef `json:"stages"`
|
||||
ParticipationPolicy map[string]any `json:"participation_policy,omitempty"`
|
||||
CompletionPolicy map[string]any `json:"completion_policy,omitempty"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
WorkflowKey string `json:"workflow_key,omitempty"`
|
||||
ProductDomains []string `json:"product_domains,omitempty"`
|
||||
RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"`
|
||||
// StageGating 为空或 strict 时按活动阶段限制编制;narrative 时阶段只标记成熟度。
|
||||
StageGating string `json:"stage_gating,omitempty"`
|
||||
Stages []StageDef `json:"stages"`
|
||||
ParticipationPolicy map[string]any `json:"participation_policy,omitempty"`
|
||||
CompletionPolicy map[string]any `json:"completion_policy,omitempty"`
|
||||
}
|
||||
|
||||
// Definition 工作流逻辑标识。
|
||||
|
||||
@@ -16,6 +16,7 @@ func ValidateDefinition(def VersionDefinition, tplReady templateReadyChecker, up
|
||||
return ValidateDefinitionV2(def, execValidate, gateValidate)
|
||||
}
|
||||
issues := make([]ValidationIssue, 0)
|
||||
issues = append(issues, stageGatingIssues(def)...)
|
||||
if def.SchemaVersion <= 0 {
|
||||
def.SchemaVersion = 1
|
||||
}
|
||||
@@ -90,3 +91,16 @@ func ValidateDefinition(def VersionDefinition, tplReady templateReadyChecker, up
|
||||
|
||||
return ValidationResult{Valid: len(issues) == 0, Issues: issues}
|
||||
}
|
||||
|
||||
func stageGatingIssues(def VersionDefinition) []ValidationIssue {
|
||||
switch strings.TrimSpace(def.StageGating) {
|
||||
case "", StageGatingStrict, StageGatingNarrative:
|
||||
return nil
|
||||
default:
|
||||
return []ValidationIssue{{
|
||||
Code: "STAGE_GATING",
|
||||
Field: "stage_gating",
|
||||
Message: "stage_gating must be strict or narrative",
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func ValidateDefinitionV2(def VersionDefinition, execValidate, gateValidate keyV
|
||||
gateValidate = defaultValidateQualityGateKey
|
||||
}
|
||||
issues := make([]ValidationIssue, 0)
|
||||
issues = append(issues, stageGatingIssues(def)...)
|
||||
if def.SchemaVersion < 2 {
|
||||
issues = append(issues, ValidationIssue{Code: "SCHEMA_VERSION", Message: "schema_version must be >= 2"})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ActivationPreview,
|
||||
DocTaskPlan,
|
||||
} from '../../types/projectSettings';
|
||||
import { isNarrativeStageGating } from '../../lib/stageGating';
|
||||
import {
|
||||
frozenDutySnapshot,
|
||||
projectSettingsChrome,
|
||||
@@ -433,7 +434,9 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
|
||||
<div style={{ fontWeight: 600, marginBottom: '8px' }}>当前工作流已激活</div>
|
||||
<div>工作流:{settings.active_workflow_key || plan?.workflow_definition_key || '—'}</div>
|
||||
<div>版本:v{plan?.workflow_version ?? settings.workflow_selection_version}</div>
|
||||
<div>当前阶段:{settings.active_stage_key || '—'}</div>
|
||||
<div>
|
||||
{isNarrativeStageGating(plan) ? '成熟度标记' : '当前阶段'}:{settings.active_stage_key || '—'}
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', color: 'var(--text-muted)', fontSize: '12px' }}>
|
||||
当前工作流已冻结,只读查看。规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../api/projectSettings';
|
||||
import { StageReadiness } from '../../types/projectSettings';
|
||||
import { canSubmitAdvance, recheckAckRequired } from '../../lib/recheckAck';
|
||||
import { isNarrativeStageGating } from '../../lib/stageGating';
|
||||
|
||||
interface StageAdvanceBannerProps {
|
||||
projectId: number;
|
||||
@@ -27,6 +28,7 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
|
||||
const [readiness, setReadiness] = useState<StageReadiness | null>(null);
|
||||
const [lastNotificationId, setLastNotificationId] = useState(0);
|
||||
const [acked, setAcked] = useState(false);
|
||||
const [narrative, setNarrative] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -37,9 +39,11 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
|
||||
if (!live || live.status === 'completed') {
|
||||
setInstanceId(null);
|
||||
setReadiness(null);
|
||||
setNarrative(false);
|
||||
return;
|
||||
}
|
||||
setInstanceId(live.id);
|
||||
setNarrative(isNarrativeStageGating(settings.plan));
|
||||
const activeStage = settings.active_stage_key || '';
|
||||
if (!activeStage) return;
|
||||
const rd = await fetchStageReadiness(live.id, activeStage);
|
||||
@@ -132,10 +136,16 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
|
||||
<AlertTriangle size={14} style={{ color: 'var(--amber-500, #f59e0b)' }} />
|
||||
)}
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
当前阶段:{readiness.stage_name || readiness.stage_key}
|
||||
{narrative ? '成熟度标记' : '当前阶段'}:{readiness.stage_name || readiness.stage_key}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
{readiness.ready ? '· 必需文档已批准,可推进' : '· 等待必需文档批准'}
|
||||
{narrative
|
||||
? readiness.ready
|
||||
? '· 本阶段文档已齐,可标记进入下一成熟度'
|
||||
: '· 标记下一成熟度前,需本阶段文档批准'
|
||||
: readiness.ready
|
||||
? '· 必需文档已批准,可推进'
|
||||
: '· 等待必需文档批准'}
|
||||
</span>
|
||||
</div>
|
||||
{isCreator && readiness.can_advance && (
|
||||
@@ -156,7 +166,7 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
|
||||
}}
|
||||
>
|
||||
{advancing ? <Loader2 size={12} /> : <ArrowRight size={12} />}
|
||||
确认推进
|
||||
{narrative ? '标记推进' : '确认推进'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../api/docgov';
|
||||
import { requestReopen } from '../api/projectTrace';
|
||||
import { fetchProjectSettings } from '../api/projectSettings';
|
||||
import { stageKeyForDocTask } from '../lib/stageGating';
|
||||
import type { DocumentAST } from '../types/block';
|
||||
import type { DocLifecycleState, UserProfile } from '../types/docgov';
|
||||
import type { ChatMessage, DeliverableItem } from '../types/workbench';
|
||||
@@ -252,7 +253,10 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu
|
||||
if (settings?.live_instance?.id) {
|
||||
instanceId = settings.live_instance.id;
|
||||
}
|
||||
if (settings?.active_stage_key) {
|
||||
const ownedStage = stageKeyForDocTask(settings?.plan, docAST.taskKey, '');
|
||||
if (ownedStage) {
|
||||
stageKey = ownedStage;
|
||||
} else if (settings?.active_stage_key) {
|
||||
stageKey = settings.active_stage_key;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { WorkflowPlan } from '../types/projectSettings';
|
||||
import { isNarrativeStageGating, stageKeyForDocTask } from './stageGating';
|
||||
|
||||
const plan: WorkflowPlan = {
|
||||
workflow_definition_key: 'main',
|
||||
workflow_version: 2,
|
||||
stage_gating: 'narrative',
|
||||
stages: [
|
||||
{
|
||||
stage_key: 'V0.1_triage',
|
||||
enabled: true,
|
||||
doc_tasks: [{ task_key: 'bd_intake', enabled: true, duties: {} }],
|
||||
},
|
||||
{
|
||||
stage_key: 'V0.5_feasibility',
|
||||
enabled: true,
|
||||
doc_tasks: [{ task_key: 'bd_research', enabled: true, duties: {} }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('narrative plan is recognized and strict omission is not', () => {
|
||||
assert.equal(isNarrativeStageGating(plan), true);
|
||||
assert.equal(isNarrativeStageGating({ ...plan, stage_gating: undefined }), false);
|
||||
});
|
||||
|
||||
test('doc task maps to its own stage even when another stage is active', () => {
|
||||
assert.equal(stageKeyForDocTask(plan, 'bd_research', 'V0.1_triage'), 'V0.5_feasibility');
|
||||
assert.equal(stageKeyForDocTask(plan, 'missing', 'V0.1_triage'), 'V0.1_triage');
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { WorkflowPlan } from '../types/projectSettings';
|
||||
|
||||
export function isNarrativeStageGating(plan?: WorkflowPlan | null): boolean {
|
||||
return plan?.stage_gating === 'narrative';
|
||||
}
|
||||
|
||||
/** 文档再修订应回到该文档所属阶段,而不是当前活动阶段。 */
|
||||
export function stageKeyForDocTask(
|
||||
plan: WorkflowPlan | undefined | null,
|
||||
taskKey: string,
|
||||
fallback = '',
|
||||
): string {
|
||||
const key = taskKey.trim();
|
||||
if (plan && key) {
|
||||
for (const stage of plan.stages) {
|
||||
if (stage.doc_tasks.some((task) => task.task_key === key)) {
|
||||
return stage.stage_key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export interface WorkflowPlan {
|
||||
workflow_definition_key: string;
|
||||
workflow_version: number;
|
||||
workflow_version_id?: number;
|
||||
/** narrative:阶段只标记成熟度,任意已启用文档都可编制。缺省为 strict。 */
|
||||
stage_gating?: 'narrative' | 'strict' | '';
|
||||
stages: StagePlan[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user