本地旧键模板与服务端 bd_* 行按同一发布键合并,未认领的旧键克隆在服务端已覆盖时丢掉。 Co-authored-by: Cursor <cursoragent@cursor.com>
193 lines
6.2 KiB
TypeScript
193 lines
6.2 KiB
TypeScript
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,
|
|
s: WorkflowDocumentSummary,
|
|
isNewSlot: boolean,
|
|
): void {
|
|
const hasDuties =
|
|
s.author_id != null ||
|
|
s.author_name != null ||
|
|
s.reviewer_id != null ||
|
|
s.reviewer_name != null ||
|
|
s.approver_id != null ||
|
|
s.approver_name != null;
|
|
if (hasDuties) {
|
|
base.authorId = s.author_id;
|
|
base.author = s.author_name || '';
|
|
base.reviewerId = s.reviewer_id;
|
|
base.reviewer = s.reviewer_name || '';
|
|
base.approverId = s.approver_id;
|
|
base.approver = s.approver_name || '';
|
|
base.dutyPolicy = s.duty_policy || '';
|
|
return;
|
|
}
|
|
if (isNewSlot) {
|
|
base.author = '';
|
|
base.reviewer = '';
|
|
base.approver = '';
|
|
base.dutyPolicy = '';
|
|
}
|
|
}
|
|
|
|
function mapLifecycleState(raw: string): DocLifecycleState {
|
|
switch (raw) {
|
|
case 'reviewing':
|
|
case 'approving':
|
|
case 'approved':
|
|
case 'drafting':
|
|
return raw;
|
|
default:
|
|
return 'drafting';
|
|
}
|
|
}
|
|
|
|
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, 发布任务键) 将服务端受控文档状态合并进本地 deliverables。
|
|
* 旧键 intake 与发布键 bd_intake 视为同一槽。命中后 taskKey 与 docId 以服务端为准。
|
|
* 调用方仅在「服务端清单已成功返回」(含空数组)时调用;请求失败不得调用,以免误清。
|
|
* 权威响应下会 drop 该项目上的全部 demo-seed 本地项,以及已被服务端覆盖的未认领旧键模板克隆。
|
|
*/
|
|
export function mergeWorkflowDocumentsFromServer(
|
|
prev: DeliverableItem[],
|
|
serverDocs: WorkflowDocumentSummary[],
|
|
projectId: number,
|
|
fallbackUser: { id: number; name: string },
|
|
): DeliverableItem[] {
|
|
let updated = prev.filter(
|
|
(d) => !(d.projectId === projectId && isDemoSeedDocId(d.docId)),
|
|
);
|
|
|
|
const authoritative = collapseServerDocs(serverDocs, projectId);
|
|
if (authoritative.length === 0) {
|
|
return updated;
|
|
}
|
|
|
|
for (const s of authoritative) {
|
|
const canon = canonicalTaskKey(s.task_key);
|
|
const slotIdx = updated.findIndex(
|
|
(d) =>
|
|
d.projectId === projectId &&
|
|
Boolean(d.taskKey) &&
|
|
canonicalTaskKey(d.taskKey) === canon &&
|
|
d.isFixedWorkflow !== false &&
|
|
d.governanceMode !== 'freeform',
|
|
);
|
|
|
|
const base: DeliverableItem =
|
|
slotIdx >= 0
|
|
? { ...updated[slotIdx] }
|
|
: {
|
|
docId: s.doc_id,
|
|
projectId,
|
|
taskKey: s.task_key,
|
|
title: s.title,
|
|
stage: 'V0.5',
|
|
category: 'workflow',
|
|
creatorId: fallbackUser.id,
|
|
creatorName: fallbackUser.name,
|
|
isFixedWorkflow: true,
|
|
isTemplateOnly: s.is_template_only,
|
|
lifecycleState: mapLifecycleState(s.lifecycle_state),
|
|
isClaimed: !s.is_template_only,
|
|
author: '',
|
|
reviewer: '',
|
|
approver: '',
|
|
updatedAt: (s.updated_at || '').replace('T', ' ').substring(0, 16),
|
|
governanceMode: 'controlled',
|
|
};
|
|
|
|
applyAuthoritativeDuties(base, s, slotIdx < 0);
|
|
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);
|
|
base.isClaimed = !s.is_template_only;
|
|
base.governanceMode = 'controlled';
|
|
base.isFixedWorkflow = true;
|
|
base.editLease = s.lease_holder_type
|
|
? {
|
|
holderType: s.lease_holder_type,
|
|
holderId: s.lease_holder_id,
|
|
expiresAt: s.lease_expires_at,
|
|
heartbeatAt: s.lease_heartbeat_at,
|
|
}
|
|
: undefined;
|
|
if (s.updated_at) {
|
|
base.updatedAt = s.updated_at.replace('T', ' ').substring(0, 16);
|
|
}
|
|
|
|
if (slotIdx >= 0) {
|
|
updated[slotIdx] = base;
|
|
} else {
|
|
updated.push(base);
|
|
}
|
|
}
|
|
|
|
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}:${canonicalTaskKey(d.taskKey)}`;
|
|
if (seenTaskKeys.has(key)) {
|
|
return false;
|
|
}
|
|
seenTaskKeys.add(key);
|
|
return true;
|
|
});
|
|
|
|
return updated;
|
|
}
|