feat(workflow): 商务叙事阶段允许编制任意已启用文档

This commit is contained in:
weichen
2026-09-28 07:29:59 +00:00
parent e5c2519fea
commit f46480a987
17 changed files with 401 additions and 39 deletions
+2 -1
View File
@@ -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,
+1
View File
@@ -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}
}
}
}
}
+11 -8
View File
@@ -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
+11 -4
View File
@@ -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 项目工作流实例。
+13
View File
@@ -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"
+44
View File
@@ -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)
}
}
+28 -21
View File
@@ -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 工作流逻辑标识。
+14
View File
@@ -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",
}}
}
}
+1
View File
@@ -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>
+5 -1
View File
@@ -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 {
+32
View File
@@ -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');
});
+22
View File
@@ -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;
}
+2
View File
@@ -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[];
}