merge: fix/workflow-doc-key-unification-2026-09-21
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -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`
|
||||
|
||||
|
||||
@@ -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_*`。
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, WorkflowDocumentSummary>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 旧任务键 → 发布键。必须与 internal/workflowdef/keyspace.go 的 legacyToCanonical 逐项一致。
|
||||
* 交付物池里的后缀对齐仍用 templateMatch.normalizeTaskKeyToProject,不要在这里再写一套池逻辑。
|
||||
*/
|
||||
const LEGACY_TO_CANONICAL: Record<string, string> = {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user