merge: fix/workflow-doc-key-unification-2026-09-21
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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