diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 3fdb497c..d98795b9 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-21-workflow-doc-key-unification | fix/workflow-doc-key-unification-2026-09-21 | .worktrees/fix-workflow-doc-key-unification-2026-09-21 | docgov+web | in_progress | | Cursor | 2026-09-21-dev-deep-test-four-issues | fix/dev-deep-test-four-issues-2026-09-21 | - | web+intent+taskrun | done | | Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | done | | Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done | @@ -176,7 +177,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-21 | 四问题修复已重建 dev;`/health` schema_version=59;pi-agent healthy | - | +| Cursor | merging | 2026-09-21 | fix/workflow-doc-key-unification-2026-09-21:doc_proj 发布键唯一与列表归并 | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-21-workflow-doc-key-unification.md b/.doc/ops/short-term/2026-09-21-workflow-doc-key-unification.md new file mode 100644 index 00000000..bfc5fb45 --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-workflow-doc-key-unification.md @@ -0,0 +1,51 @@ +--- +title: "工作流文档键统一(项目 6579)" +status: in_progress +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: fix/workflow-doc-key-unification-2026-09-21 +worktree: .worktrees/fix-workflow-doc-key-unification-2026-09-21 +mutex: docgov+web +--- + +# 工作流文档键统一(项目 6579) + +## 目标 + +管理员与成员看到的工作流文档都是 7 份 `bd_*`,不再因旧键 `intake` 与发布键 `bd_intake` 各算一行而变成 8 或 14。 + +## 范围 + +- 迁移 00060:归档并存的旧键正式台账,并为 `doc_proj*` 加发布键唯一索引。 +- 前端按发布键合并本地模板克隆。 +- 文档列表按发布键归并,物化时不再插入已有旧键的兄弟行。 + +## 非目标 + +- 不改项目 143。 +- 不清理 `doc_test_db_*` 测试残留。 +- 不重做「编写用户初始需求收集表」的作用域解析(2026-09-21 早间修复已覆盖,本任务只复测)。 + +## 步骤 + +1. [x] 从 `dev` 开 `fix/workflow-doc-key-unification-2026-09-21` +2. [x] 迁移、前端合并、列表归并与单测 +3. [ ] 合入 dev、重建、核对项目 6579 为 7 条 `bd_*` +4. [ ] 测试报告与 CURRENT / FEATURE-MATRIX + +## 验收标准 + +- [ ] `GET /api/v1/projects/6579/documents?category=workflow` 恰好 7 条 `bd_*` +- [ ] 项目 143 文档数不变 +- [ ] 「编写用户初始需求收集表」返回 202,而不是任务作用域 409 + +## 相关链接 + +- Checklist: +- Handoff: +- Test report: `.doc/test-reports/2026-09-21-workflow-doc-key-unification-test-report.md` + +## 证据 / 备注 + +迁移已在 dev 库事务内演练并回滚:归档 1 行后项目 6579 为 7 条 `bd_*`,项目 143 仍为 1 行。唯一索引只覆盖 `doc_proj*`,因为项目 1 有大量同键 `doc_test_db_*`。 diff --git a/deploy/postgres/goose/00060_2026-09-21-document-canonical-task-key.sql b/deploy/postgres/goose/00060_2026-09-21-document-canonical-task-key.sql new file mode 100644 index 00000000..5a0b0a8f --- /dev/null +++ b/deploy/postgres/goose/00060_2026-09-21-document-canonical-task-key.sql @@ -0,0 +1,176 @@ +-- +goose Up +-- 正式台账 doc_proj* 上,旧任务键与发布键(intake 与 bd_intake)不能再并存。 +-- 只归档「同一项目里已经有发布键行」的旧键行;孤立旧键行保留,避免晋升时丢掉唯一数据。 +-- 唯一索引不覆盖 doc_test_db_*:dev 库项目 1 有大量同键测试残留,全表唯一会误伤。 +-- document_snapshots / document_content_blobs 有不可变触发器,不删除;正文副本写入归档 payload。 + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION canonical_document_task_key(task_key text) +RETURNS text +LANGUAGE sql +IMMUTABLE +PARALLEL SAFE +AS $$ + SELECT CASE btrim(COALESCE(task_key, '')) + WHEN 'intake' THEN 'bd_intake' + WHEN 'research' THEN 'bd_research' + WHEN 'feasibility_dev' THEN 'bd_feasibility_dev' + WHEN 'feasibility_reg' THEN 'bd_feasibility_reg' + WHEN 'user_requirements' THEN 'bd_user_requirements' + WHEN 'prelim_design' THEN 'bd_prelim_design' + WHEN 'cdo_quote' THEN 'bd_cdo_quote' + WHEN 'system_reqs' THEN 'pm_system_reqs' + WHEN 'test_manager' THEN 'tm_system_verification' + WHEN 'software_charter' THEN 'sw_software_charter' + WHEN 'source_inventory' THEN 'sw_source_inventory' + WHEN 'current_state_audit' THEN 'sw_current_state_audit' + WHEN 'architecture_baseline' THEN 'sw_architecture_baseline' + WHEN 'software_risk_register' THEN 'sw_software_risk_register' + WHEN 'product_requirements' THEN 'sw_product_requirements' + WHEN 'scope_backlog' THEN 'sw_scope_backlog' + WHEN 'target_architecture' THEN 'sw_target_architecture' + WHEN 'decision_log' THEN 'sw_decision_log' + WHEN 'bootstrap_roadmap' THEN 'sw_bootstrap_roadmap' + WHEN 'verification_strategy' THEN 'sw_verification_strategy' + WHEN 'operations_baseline' THEN 'sw_operations_baseline' + WHEN 'bootstrap_readiness' THEN 'sw_bootstrap_readiness' + WHEN 'change_brief' THEN 'sw_change_brief' + WHEN 'implementation_plan' THEN 'sw_implementation_plan' + WHEN 'change_manifest' THEN 'sw_change_manifest' + WHEN 'adr_delta' THEN 'sw_adr_delta' + WHEN 'test_report' THEN 'sw_test_report' + WHEN 'security_review' THEN 'sw_security_review' + WHEN 'release_plan' THEN 'sw_release_plan' + WHEN 'release_notes' THEN 'sw_release_notes' + WHEN 'retrospective' THEN 'sw_retrospective' + WHEN 'backlog_delta' THEN 'sw_backlog_delta' + ELSE btrim(COALESCE(task_key, '')) + END; +$$; +-- +goose StatementEnd + +CREATE TABLE document_records_legacy_key_archive (LIKE document_records INCLUDING DEFAULTS); +ALTER TABLE document_records_legacy_key_archive ALTER COLUMN id DROP DEFAULT; +ALTER TABLE document_records_legacy_key_archive + ADD COLUMN archive_id BIGSERIAL PRIMARY KEY, + ADD COLUMN archived_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ADD COLUMN archive_reason TEXT NOT NULL DEFAULT '', + ADD COLUMN related_payload JSONB NOT NULL DEFAULT '{}'::jsonb; + +CREATE TABLE document_content_projections_legacy_key_archive (LIKE document_content_projections INCLUDING DEFAULTS); +ALTER TABLE document_content_projections_legacy_key_archive + ADD COLUMN archive_id BIGSERIAL PRIMARY KEY, + ADD COLUMN archived_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ADD COLUMN archive_reason TEXT NOT NULL DEFAULT ''; + +CREATE TEMP TABLE legacy_key_docs_to_archive ON COMMIT DROP AS +SELECT legacy.doc_id +FROM document_records legacy +JOIN document_records canon + ON canon.project_id = legacy.project_id + AND canon.doc_id <> legacy.doc_id + AND canon.doc_id LIKE 'doc_proj%' + AND legacy.doc_id LIKE 'doc_proj%' + AND canonical_document_task_key(canon.task_key) = canonical_document_task_key(legacy.task_key) + AND canon.task_key = canonical_document_task_key(canon.task_key) + AND legacy.task_key <> canonical_document_task_key(legacy.task_key); + +INSERT INTO document_records_legacy_key_archive +SELECT d.*, + nextval('document_records_legacy_key_archive_archive_id_seq'), + now(), + 'coexisting_legacy_task_key', + '{}'::jsonb +FROM document_records d +JOIN legacy_key_docs_to_archive x ON x.doc_id = d.doc_id; + +UPDATE document_records_legacy_key_archive a +SET related_payload = jsonb_build_object( + 'comments', COALESCE(( + SELECT jsonb_agg(to_jsonb(c)) FROM document_comments c WHERE c.doc_id = a.doc_id + ), '[]'::jsonb), + 'change_proposals', COALESCE(( + SELECT jsonb_agg(to_jsonb(p)) FROM document_change_proposals p WHERE p.doc_id = a.doc_id + ), '[]'::jsonb), + 'audit_logs', COALESCE(( + SELECT jsonb_agg(to_jsonb(l)) FROM document_audit_logs l WHERE l.doc_id = a.doc_id + ), '[]'::jsonb), + 'versions', COALESCE(( + SELECT jsonb_agg(to_jsonb(v)) FROM document_versions v WHERE v.doc_id = a.doc_id + ), '[]'::jsonb), + 'snapshots', COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'id', s.id, + 'content_sha256', s.content_sha256, + 'source_kind', s.source_kind, + 'created_at', s.created_at, + 'markdown', b.content_markdown + )) + FROM document_snapshots s + JOIN document_content_blobs b ON b.content_sha256 = s.content_sha256 + WHERE s.doc_id = a.doc_id + ), '[]'::jsonb) +); + +INSERT INTO document_content_projections_legacy_key_archive +SELECT p.*, + nextval('document_content_projections_legacy_key_archive_archive_id_seq'), + now(), + 'coexisting_legacy_task_key' +FROM document_content_projections p +JOIN legacy_key_docs_to_archive x ON x.doc_id = p.doc_id; + +DELETE FROM document_content_projections p +USING legacy_key_docs_to_archive x +WHERE p.doc_id = x.doc_id; + +DELETE FROM document_records d +USING legacy_key_docs_to_archive x +WHERE d.doc_id = x.doc_id; + +-- +goose StatementBegin +DO $$ +DECLARE + dup text; +BEGIN + SELECT string_agg(project_id::text || ':' || canon, ', ') + INTO dup + FROM ( + SELECT project_id, canonical_document_task_key(task_key) AS canon + FROM document_records + WHERE btrim(task_key) <> '' AND doc_id LIKE 'doc_proj%' + GROUP BY project_id, canonical_document_task_key(task_key) + HAVING count(*) > 1 + ) s; + IF dup IS NOT NULL THEN + RAISE EXCEPTION 'doc_proj canonical task key still duplicated: %', dup; + END IF; +END $$; +-- +goose StatementEnd + +CREATE UNIQUE INDEX uq_document_records_doc_proj_canonical_task_key + ON document_records (project_id, canonical_document_task_key(task_key)) + WHERE btrim(task_key) <> '' AND doc_id LIKE 'doc_proj%'; + +-- +goose Down +DROP INDEX IF EXISTS uq_document_records_doc_proj_canonical_task_key; + +INSERT INTO document_records +SELECT (jsonb_populate_record( + NULL::document_records, + to_jsonb(a) - 'archive_id' - 'archived_at' - 'archive_reason' - 'related_payload' +)).* +FROM document_records_legacy_key_archive a +ON CONFLICT (doc_id) DO NOTHING; + +INSERT INTO document_content_projections +SELECT (jsonb_populate_record( + NULL::document_content_projections, + to_jsonb(a) - 'archive_id' - 'archived_at' - 'archive_reason' +)).* +FROM document_content_projections_legacy_key_archive a +ON CONFLICT (doc_id, snapshot_id) DO NOTHING; + +DROP TABLE IF EXISTS document_content_projections_legacy_key_archive; +DROP TABLE IF EXISTS document_records_legacy_key_archive; +DROP FUNCTION IF EXISTS canonical_document_task_key(text); diff --git a/internal/docgov/materialize.go b/internal/docgov/materialize.go index be733c65..bbe0fdee 100644 --- a/internal/docgov/materialize.go +++ b/internal/docgov/materialize.go @@ -3,9 +3,14 @@ package docgov import ( "context" "encoding/json" + "errors" "fmt" "strings" "time" + + "chat-agent-system/internal/workflowdef" + + "github.com/jackc/pgx/v5" ) // TemplateDocSpec 是激活后要落到 document_records 的受控文档槽。 @@ -48,6 +53,12 @@ func (e *GovernanceEngine) MaterializeTemplateDocs(ctx context.Context, projectI func (e *GovernanceEngine) upsertTemplateDoc(ctx context.Context, projectID int64, spec TemplateDocSpec) error { taskKey := strings.TrimSpace(spec.TaskKey) docID := ControlledDocID(projectID, taskKey) + if taken, err := e.hasCanonicalSibling(ctx, projectID, taskKey, docID); err != nil { + return err + } else if taken { + // 同一发布键已有另一行(常见于旧键 intake 仍是唯一数据)。不再插入 bd_* 兄弟行。 + return nil + } title := strings.TrimSpace(spec.Title) if title == "" { title = taskKey @@ -155,3 +166,51 @@ func dutyDisplayName(userID int64, name string) string { } return "" } + +func siblingTaskKeys(taskKey string) []string { + canon := workflowdef.CanonicalTaskKey(taskKey) + legacy := workflowdef.ImplementationTaskKey(canon) + var out []string + if canon != "" && canon != taskKey { + out = append(out, canon) + } + if legacy != "" && legacy != taskKey && legacy != canon { + out = append(out, legacy) + } + return out +} + +func (e *GovernanceEngine) hasCanonicalSibling(ctx context.Context, projectID int64, taskKey, docID string) (bool, error) { + siblings := siblingTaskKeys(taskKey) + if e == nil || len(siblings) == 0 { + return false, nil + } + if e.pool != nil { + var existing string + err := e.pool.QueryRow(ctx, ` + SELECT doc_id FROM document_records + WHERE project_id = $1 AND task_key = ANY($2::text[]) AND doc_id <> $3 + LIMIT 1 + `, projectID, siblings, docID).Scan(&existing) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("docgov: lookup canonical sibling: %w", err) + } + return existing != "", nil + } + e.mu.RLock() + defer e.mu.RUnlock() + for _, doc := range e.memDocs { + if doc == nil || doc.ProjectID != projectID || doc.DocID == docID { + continue + } + for _, key := range siblings { + if doc.TaskKey == key { + return true, nil + } + } + } + return false, nil +} diff --git a/internal/docgov/materialize_test.go b/internal/docgov/materialize_test.go index 56cf21b2..c501b13b 100644 --- a/internal/docgov/materialize_test.go +++ b/internal/docgov/materialize_test.go @@ -65,3 +65,29 @@ func TestMaterializeTemplateDocsIdempotentAndSkipsRealContent(t *testing.T) { t.Fatalf("real document was overwritten: %+v", got) } } + +func TestMaterializeTemplateDocsSkipsCanonicalSibling(t *testing.T) { + ctx := context.Background() + engine := docgov.NewGovernanceEngine(nil) + if _, err := engine.CreateOrGetDocument( + ctx, "doc_proj70_intake", 70, "intake", "用户初始需求收集表", "V0.1", "", + 1, "编制人", 0, "", 0, "", + ); err != nil { + t.Fatal(err) + } + if err := engine.MaterializeTemplateDocs(ctx, 70, []docgov.TemplateDocSpec{{ + TaskKey: "bd_intake", + Title: "用户初始需求收集表", + StageKey: "V0.1_triage", + Enabled: true, + }}); err != nil { + t.Fatal(err) + } + docs, err := engine.ListWorkflowDocumentsByProject(ctx, 70) + if err != nil { + t.Fatal(err) + } + if len(docs) != 1 || docs[0].DocID != "doc_proj70_intake" || docs[0].TaskKey != "intake" { + t.Fatalf("sibling bd_intake was inserted: %+v", docs) + } +} diff --git a/internal/docgov/project_docs.go b/internal/docgov/project_docs.go index 96294223..8312edf0 100644 --- a/internal/docgov/project_docs.go +++ b/internal/docgov/project_docs.go @@ -98,7 +98,10 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p } out = append(out, item) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + return collapseWorkflowDocuments(out), nil } e.mu.Lock() @@ -130,7 +133,7 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p }) } } - return out, nil + return collapseWorkflowDocuments(out), nil } // ReadDocumentContent 读取受控文档 output.md 并返回 typed ContentError。 diff --git a/internal/docgov/project_docs_test.go b/internal/docgov/project_docs_test.go index 560559d7..64f9568b 100644 --- a/internal/docgov/project_docs_test.go +++ b/internal/docgov/project_docs_test.go @@ -79,3 +79,41 @@ func TestListWorkflowDocumentsIncludesAuthoritativeDuties(t *testing.T) { t.Fatalf("stranger must not become author: %+v", got) } } + +func TestListWorkflowDocumentsCollapsesLegacyAlias(t *testing.T) { + engine := docgov.NewGovernanceEngine(nil) + ctx := context.Background() + const projectID = int64(6579) + seeds := []struct { + docID string + taskKey string + title string + }{ + {"doc_proj6579_intake", "intake", "用户初始需求收集表"}, + {"doc_proj6579_bd_intake", "bd_intake", "用户初始需求收集表"}, + {"doc_proj6579_bd_research", "bd_research", "调研报告"}, + } + for _, seed := range seeds { + if _, err := engine.CreateOrGetDocument( + ctx, seed.docID, projectID, seed.taskKey, seed.title, "V0.1", "", + 398, "陈威", 0, "", 0, "", + ); err != nil { + t.Fatal(err) + } + } + docs, err := engine.ListWorkflowDocumentsByProject(ctx, projectID) + if err != nil { + t.Fatal(err) + } + if len(docs) != 2 { + t.Fatalf("documents=%d, want bd_intake and bd_research only", len(docs)) + } + for _, doc := range docs { + if doc.TaskKey == "intake" { + t.Fatalf("legacy intake row leaked into the list: %+v", doc) + } + } + if docs[0].TaskKey != "bd_intake" || docs[0].DocID != "doc_proj6579_bd_intake" { + t.Fatalf("first doc=%+v", docs[0]) + } +} diff --git a/internal/docgov/workflow_doc_collapse.go b/internal/docgov/workflow_doc_collapse.go new file mode 100644 index 00000000..8752fc7a --- /dev/null +++ b/internal/docgov/workflow_doc_collapse.go @@ -0,0 +1,60 @@ +package docgov + +import ( + "sort" + + "chat-agent-system/internal/workflowdef" +) + +// collapseWorkflowDocuments 把同一发布键的旧键行与新键行收成一行。 +// 发布键优先;都是发布键时保留已脱离模板的那条;再按 doc_id 稳定选取。 +func collapseWorkflowDocuments(docs []WorkflowDocumentSummary) []WorkflowDocumentSummary { + if len(docs) < 2 { + return docs + } + best := make(map[string]WorkflowDocumentSummary, len(docs)) + for _, doc := range docs { + canon := workflowdef.CanonicalTaskKey(doc.TaskKey) + if canon == "" { + continue + } + prev, ok := best[canon] + if !ok { + best[canon] = doc + continue + } + best[canon] = preferWorkflowDocument(prev, doc) + } + out := make([]WorkflowDocumentSummary, 0, len(best)) + for _, doc := range best { + out = append(out, doc) + } + sort.Slice(out, func(i, j int) bool { + if out[i].TaskKey == out[j].TaskKey { + return out[i].DocID < out[j].DocID + } + return out[i].TaskKey < out[j].TaskKey + }) + return out +} + +func preferWorkflowDocument(current, candidate WorkflowDocumentSummary) WorkflowDocumentSummary { + currentPublished := current.TaskKey == workflowdef.CanonicalTaskKey(current.TaskKey) + candidatePublished := candidate.TaskKey == workflowdef.CanonicalTaskKey(candidate.TaskKey) + if currentPublished != candidatePublished { + if candidatePublished { + return candidate + } + return current + } + if current.IsTemplateOnly != candidate.IsTemplateOnly { + if candidate.IsTemplateOnly { + return current + } + return candidate + } + if candidate.DocID < current.DocID { + return candidate + } + return current +} diff --git a/web/src/components/chat/deliverableSync.test.ts b/web/src/components/chat/deliverableSync.test.ts index 095f84f7..d305f541 100644 --- a/web/src/components/chat/deliverableSync.test.ts +++ b/web/src/components/chat/deliverableSync.test.ts @@ -277,4 +277,71 @@ describe('mergeWorkflowDocumentsFromServer', () => { expect(merged).toHaveLength(1); expect(merged[0].docId).toBe('doc_proj6001_intake'); }); + + it('collapses seven legacy local clones and a coexisting intake row into seven bd_ docs', () => { + const legacy = [ + ['intake', '用户初始需求收集表'], + ['research', '调研报告(竞品/技术/专利/市场/对比/法规)'], + ['feasibility_reg', '注册路径可行性评估报告(法规细则)'], + ['feasibility_dev', '设计开发可行性评估报告(研发细则)'], + ['user_requirements', '用户需求规格说明书(URS 长标题)'], + ['prelim_design', '初步设计说明书(长标题)'], + ['cdo_quote', 'CDO报价核算表(长标题)'], + ] as const; + const prev: DeliverableItem[] = legacy.map(([taskKey, title]) => ({ + docId: `doc-p6579-${taskKey}`, + projectId: 6579, + taskKey, + title, + stage: 'V0.5', + category: 'workflow', + creatorId: 398, + creatorName: '陈威', + isFixedWorkflow: true, + isTemplateOnly: true, + lifecycleState: 'drafting', + isClaimed: false, + author: '', + reviewer: '', + approver: '', + updatedAt: '2026-09-21', + governanceMode: 'controlled', + })); + prev.push({ + ...prev[0], + docId: 'doc-p99-intake', + projectId: 99, + title: '未激活项目的唯一模板', + }); + + const server = legacy.map(([taskKey, title]) => ({ + doc_id: `doc_proj6579_bd_${taskKey}`, + project_id: 6579, + task_key: `bd_${taskKey}`, + title: title.split('(')[0], + lifecycle_state: 'drafting' as const, + is_template_only: true, + })); + server.push({ + doc_id: 'doc_proj6579_intake', + project_id: 6579, + task_key: 'intake', + title: '用户初始需求收集表', + lifecycle_state: 'drafting', + is_template_only: false, + }); + + const merged = mergeWorkflowDocumentsFromServer(prev, server, 6579, { id: 398, name: '陈威' }); + const projectDocs = merged.filter((item) => item.projectId === 6579); + expect(projectDocs).toHaveLength(7); + expect(projectDocs.map((item) => item.taskKey).sort()).toEqual( + legacy.map(([taskKey]) => `bd_${taskKey}`).sort(), + ); + expect(new Set(projectDocs.map((item) => item.docId)).size).toBe(7); + expect(projectDocs.some((item) => item.taskKey === 'intake' || item.taskKey === 'research')).toBe(false); + expect(projectDocs.some((item) => item.title.includes('竞品'))).toBe(false); + const intake = projectDocs.find((item) => item.taskKey === 'bd_intake'); + expect(intake?.docId).toBe('doc_proj6579_bd_intake'); + expect(merged.find((item) => item.projectId === 99)?.docId).toBe('doc-p99-intake'); + }); }); diff --git a/web/src/lib/deliverableSync.ts b/web/src/lib/deliverableSync.ts index 2a4092c5..00065ccd 100644 --- a/web/src/lib/deliverableSync.ts +++ b/web/src/lib/deliverableSync.ts @@ -2,6 +2,7 @@ import type { WorkflowDocumentSummary } from '../api/projectDocuments'; import type { DeliverableItem } from '../types/workbench'; import type { DocLifecycleState } from '../types/docgov'; import { isDemoSeedDocId } from '../data/demoSeedIds'; +import { canonicalTaskKey } from './taskKeySpace'; function applyAuthoritativeDuties( base: DeliverableItem, @@ -45,10 +46,41 @@ function mapLifecycleState(raw: string): DocLifecycleState { } } +function preferServerDoc( + current: WorkflowDocumentSummary, + candidate: WorkflowDocumentSummary, +): WorkflowDocumentSummary { + const currentPublished = current.task_key === canonicalTaskKey(current.task_key); + const candidatePublished = candidate.task_key === canonicalTaskKey(candidate.task_key); + if (currentPublished !== candidatePublished) { + return candidatePublished ? candidate : current; + } + if (current.is_template_only !== candidate.is_template_only) { + return candidate.is_template_only ? current : candidate; + } + return candidate.doc_id < current.doc_id ? candidate : current; +} + +/** 同一发布键只留一行。发布键优先于旧键,已有正文优先于模板。 */ +function collapseServerDocs( + serverDocs: WorkflowDocumentSummary[], + projectId: number, +): WorkflowDocumentSummary[] { + const byCanon = new Map(); + for (const doc of serverDocs) { + if (doc.project_id !== projectId || !doc.task_key) continue; + const canon = canonicalTaskKey(doc.task_key); + const prev = byCanon.get(canon); + byCanon.set(canon, prev ? preferServerDoc(prev, doc) : doc); + } + return [...byCanon.values()]; +} + /** - * 按 (projectId, taskKey) 将服务端受控文档状态合并进本地 deliverables。 + * 按 (projectId, 发布任务键) 将服务端受控文档状态合并进本地 deliverables。 + * 旧键 intake 与发布键 bd_intake 视为同一槽。命中后 taskKey 与 docId 以服务端为准。 * 调用方仅在「服务端清单已成功返回」(含空数组)时调用;请求失败不得调用,以免误清。 - * 权威响应下会 drop 该项目上的全部 demo-seed 本地项。 + * 权威响应下会 drop 该项目上的全部 demo-seed 本地项,以及已被服务端覆盖的未认领旧键模板克隆。 */ export function mergeWorkflowDocumentsFromServer( prev: DeliverableItem[], @@ -60,17 +92,18 @@ export function mergeWorkflowDocumentsFromServer( (d) => !(d.projectId === projectId && isDemoSeedDocId(d.docId)), ); - if (serverDocs.length === 0) { + const authoritative = collapseServerDocs(serverDocs, projectId); + if (authoritative.length === 0) { return updated; } - for (const s of serverDocs) { - if (s.project_id !== projectId || !s.task_key) continue; - + for (const s of authoritative) { + const canon = canonicalTaskKey(s.task_key); const slotIdx = updated.findIndex( (d) => d.projectId === projectId && - d.taskKey === s.task_key && + Boolean(d.taskKey) && + canonicalTaskKey(d.taskKey) === canon && d.isFixedWorkflow !== false && d.governanceMode !== 'freeform', ); @@ -102,6 +135,7 @@ export function mergeWorkflowDocumentsFromServer( base.dutiesAuthoritative = true; base.docId = s.doc_id; + base.taskKey = s.task_key; base.title = s.title || base.title; base.isTemplateOnly = s.is_template_only; base.lifecycleState = mapLifecycleState(s.lifecycle_state); @@ -127,13 +161,26 @@ export function mergeWorkflowDocumentsFromServer( } } - // 去除同一 (projectId, taskKey) 的重复本地 ID(如 doc-p56-intake 与 doc_proj56_intake 并存) + const serverByCanon = new Map( + authoritative.map((doc) => [canonicalTaskKey(doc.task_key), doc]), + ); + // 服务端已覆盖该发布键时,丢掉未认领的旧键模板克隆。已认领或服务端没有的槽保留。 + updated = updated.filter((d) => { + if (d.projectId !== projectId || !d.taskKey || d.governanceMode === 'freeform') { + return true; + } + const server = serverByCanon.get(canonicalTaskKey(d.taskKey)); + if (!server || d.docId === server.doc_id) return true; + return !(d.isTemplateOnly && !d.isClaimed); + }); + + // 同一发布键只留一行(intake 与 bd_intake 不再各占一条) const seenTaskKeys = new Set(); updated = updated.filter((d) => { if (d.projectId !== projectId || !d.taskKey || d.governanceMode === 'freeform') { return true; } - const key = `${d.projectId}:${d.taskKey}`; + const key = `${d.projectId}:${canonicalTaskKey(d.taskKey)}`; if (seenTaskKeys.has(key)) { return false; } diff --git a/web/src/lib/taskKeySpace.ts b/web/src/lib/taskKeySpace.ts new file mode 100644 index 00000000..dbd2757d --- /dev/null +++ b/web/src/lib/taskKeySpace.ts @@ -0,0 +1,43 @@ +/** + * 旧任务键 → 发布键。必须与 internal/workflowdef/keyspace.go 的 legacyToCanonical 逐项一致。 + * 交付物池里的后缀对齐仍用 templateMatch.normalizeTaskKeyToProject,不要在这里再写一套池逻辑。 + */ +const LEGACY_TO_CANONICAL: Record = { + intake: 'bd_intake', + research: 'bd_research', + feasibility_dev: 'bd_feasibility_dev', + feasibility_reg: 'bd_feasibility_reg', + user_requirements: 'bd_user_requirements', + prelim_design: 'bd_prelim_design', + cdo_quote: 'bd_cdo_quote', + system_reqs: 'pm_system_reqs', + test_manager: 'tm_system_verification', + software_charter: 'sw_software_charter', + source_inventory: 'sw_source_inventory', + current_state_audit: 'sw_current_state_audit', + architecture_baseline: 'sw_architecture_baseline', + software_risk_register: 'sw_software_risk_register', + product_requirements: 'sw_product_requirements', + scope_backlog: 'sw_scope_backlog', + target_architecture: 'sw_target_architecture', + decision_log: 'sw_decision_log', + bootstrap_roadmap: 'sw_bootstrap_roadmap', + verification_strategy: 'sw_verification_strategy', + operations_baseline: 'sw_operations_baseline', + bootstrap_readiness: 'sw_bootstrap_readiness', + change_brief: 'sw_change_brief', + implementation_plan: 'sw_implementation_plan', + change_manifest: 'sw_change_manifest', + adr_delta: 'sw_adr_delta', + test_report: 'sw_test_report', + security_review: 'sw_security_review', + release_plan: 'sw_release_plan', + release_notes: 'sw_release_notes', + retrospective: 'sw_retrospective', + backlog_delta: 'sw_backlog_delta', +}; + +export function canonicalTaskKey(key: string): string { + const trimmed = key.trim(); + return LEGACY_TO_CANONICAL[trimmed] ?? trimmed; +}