From baca45ab9cd082d37cd80f5fdcd465bda71e94f1 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:37:10 +0000 Subject: [PATCH] fix(web): merge workflow documents by canonical task key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地旧键模板与服务端 bd_* 行按同一发布键合并,未认领的旧键克隆在服务端已覆盖时丢掉。 Co-authored-by: Cursor --- web/src/lib/deliverableSync.ts | 65 +++++++++++++++++++++++++++++----- web/src/lib/taskKeySpace.ts | 43 ++++++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/taskKeySpace.ts 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; +}