import type { AgentDecomposeTarget } from '@ftb/shared'; import { calcActualElapsedHours, formatActualDuration, type TimeInterval } from './work-hours'; export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed'; export type ProductPlanKind = 'design' | 'review'; export type ProductPlanReviewResult = 'passed' | 'failed'; export type RequirementCoverageStatus = 'not_started' | 'partial' | 'completed'; export type VersionPlanLogType = 'requirement_progress' | 'research_direction_progress' | 'ai_decompose' | 'system'; export type AiDecomposeLogStatus = 'started' | 'completed' | 'error'; export type ProductPlanReviewFailureType = | 'requirement_mismatch' | 'information_architecture' | 'interaction_flow' | 'state_coverage' | 'edge_case_missing' | 'business_rule_gap' | 'role_permission_gap' | 'data_rule_gap' | 'copywriting_ambiguity' | 'risk_dependency'; export const PRODUCT_PLAN_KIND_LABEL: Record = { design: '设计方案', review: '方案评审', }; export const PRODUCT_PLAN_REVIEW_RESULT_LABEL: Record = { passed: '评审通过', failed: '评审不通过', }; export const PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS: { value: ProductPlanReviewFailureType; label: string }[] = [ { value: 'requirement_mismatch', label: '需求覆盖不完整/偏离需求' }, { value: 'information_architecture', label: '信息架构或页面层级不清晰' }, { value: 'interaction_flow', label: '关键交互流程不闭环' }, { value: 'state_coverage', label: '缺少状态、异常或空数据场景' }, { value: 'edge_case_missing', label: '边界场景考虑不足' }, { value: 'business_rule_gap', label: '业务规则、审批或口径缺失' }, { value: 'role_permission_gap', label: '角色权限与可见范围不明确' }, { value: 'data_rule_gap', label: '字段、数据来源或计算逻辑不清楚' }, { value: 'copywriting_ambiguity', label: '文案表达有歧义或误导' }, { value: 'risk_dependency', label: '依赖、风险或上线影响未说明' }, ]; export interface PlanTask { id: string; title: string; status: PlanTaskStatus; completedContent?: string; remainingContent?: string; currentWorkStartedAt?: string; updatedAt?: string; updatedBy?: string; } export const RESEARCH_DIRECTION_PRESETS = [ '\u7ade\u54c1\u5206\u6790', '\u7528\u6237\u8bbf\u8c08', '\u6570\u636e\u8c03\u7814', '\u6280\u672f\u53ef\u884c\u6027\u5206\u6790', '\u5e02\u573a\u8c03\u7814', '\u9700\u6c42\u5206\u6790', ]; export function getResearchDirectionPresetOptions(tasks: Array>): Array<{ title: string; disabled: boolean; }> { const selectedTitles = new Set(tasks.map((task) => task.title.trim()).filter(Boolean)); return RESEARCH_DIRECTION_PRESETS.map((title) => ({ title, disabled: selectedTitles.has(title), })); } export interface VersionPlanRequirementCoverage { requirementId: string; status: RequirementCoverageStatus; completedContent?: string; remainingContent?: string; currentWorkStartedAt?: string; updatedAt: string; updatedBy: string; } export interface VersionPlanLog { id: string; type: VersionPlanLogType; createdAt: string; actor: string; title: string; detail?: string; requirementId?: string; requirementCode?: string; requirementTitle?: string; coverageStatus?: RequirementCoverageStatus; completedContent?: string; remainingContent?: string; workStartedAt?: string; directionTaskId?: string; directionTitle?: string; aiTarget?: AgentDecomposeTarget; aiStatus?: AiDecomposeLogStatus; } export interface RequirementCoverageWorkStartInput { requirementId: string; startedAt?: string; updatedBy: string; } export interface RequirementCoverageUpdateInput { requirementId: string; status: RequirementCoverageStatus; completedContent?: string; remainingContent?: string; workStartedAt?: string; updatedAt?: string; updatedBy: string; requirementCode?: string; requirementTitle?: string; } export interface ResearchDirectionWorkStartInput { taskId: string; startedAt?: string; updatedBy: string; } export interface ResearchDirectionProgressUpdateInput { taskId: string; status: Extract; completedContent?: string; remainingContent?: string; workStartedAt?: string; updatedAt?: string; updatedBy: string; } export type PlanLogDraft = Omit & { id?: string; createdAt?: string; }; export type VersionPlanLogView = VersionPlanLog & { planId: string; planTitle: string; }; export const REQUIREMENT_COVERAGE_LABEL: Record = { not_started: '未开始', partial: '部分完成', completed: '完全完成', }; export function getResearchDirectionStatus(task: Pick): RequirementCoverageStatus { if (task.status === 'completed') return 'completed'; if (task.status === 'in_progress') return 'partial'; return 'not_started'; } export function getResearchDirectionProgressSummary(plan: VersionPlan): { total: number; completed: number; partial: number; notStarted: number; percent: number; } { const tasks = plan.tasks ?? []; const total = tasks.length; const completed = tasks.filter((task) => getResearchDirectionStatus(task) === 'completed').length; const partial = tasks.filter((task) => getResearchDirectionStatus(task) === 'partial').length; const notStarted = Math.max(total - completed - partial, 0); return { total, completed, partial, notStarted, percent: total === 0 ? 0 : Math.round((completed / total) * 100), }; } export interface VersionPlan { id: string; versionId: string; type: 'research' | 'product' | 'ui'; title: string; owner: string; startTime: string; endTime: string; status: 'pending' | 'in_progress' | 'completed'; tasks?: PlanTask[]; completedRequirementIds?: string[]; linkedRequirementIds?: string[]; requirementCoverage?: VersionPlanRequirementCoverage[]; logs?: VersionPlanLog[]; productPlanKind?: ProductPlanKind; resultType?: 'link' | 'file'; resultTitle?: string; resultUrl?: string; resultFileName?: string; resultFileData?: string; prototypeReviewConfirmed?: boolean; reviewResult?: ProductPlanReviewResult; reviewFailureTypes?: ProductPlanReviewFailureType[]; reviewFailureReason?: string; remark?: string; overdueReason?: string; actualStartAt?: string; createdAt: string; completedAt?: string; addedBy: string; aiDecomposeStatus?: 'idle' | 'in_progress' | 'completed' | 'error'; aiDecomposeAt?: string; aiDecomposeBy?: string; aiDecomposeTarget?: AgentDecomposeTarget; aiDecomposeError?: string; } export type PlanType = VersionPlan['type']; function makePlanLogId(createdAt: string): string { const time = new Date(createdAt).getTime(); const suffix = Math.random().toString(36).slice(2, 8); return `plan-log-${Number.isFinite(time) ? time : Date.now()}-${suffix}`; } export function getRequirementCoverage(plan: VersionPlan, requirementId: string): VersionPlanRequirementCoverage | undefined { const explicit = plan.requirementCoverage?.find((item) => item.requirementId === requirementId); if (explicit) return explicit; if ((plan.completedRequirementIds ?? []).includes(requirementId)) { return { requirementId, status: 'completed', updatedAt: plan.completedAt ?? plan.createdAt, updatedBy: plan.owner, }; } return undefined; } export function getRequirementCoverageStatus(plan: VersionPlan, requirementId: string): RequirementCoverageStatus { return getRequirementCoverage(plan, requirementId)?.status ?? 'not_started'; } export function getRequirementCoverageSummary(plan: VersionPlan): { total: number; completed: number; partial: number; notStarted: number; percent: number; } { const linkedIds = plan.linkedRequirementIds ?? []; const total = linkedIds.length; const completed = linkedIds.filter((id) => getRequirementCoverageStatus(plan, id) === 'completed').length; const partial = linkedIds.filter((id) => getRequirementCoverageStatus(plan, id) === 'partial').length; const notStarted = Math.max(total - completed - partial, 0); return { total, completed, partial, notStarted, percent: total === 0 ? 0 : Math.round((completed / total) * 100), }; } export function canSaveRequirementCoverageDraft( status: RequirementCoverageStatus, completedContent?: string, remainingContent?: string, workStartedAt?: string, ): boolean { if (!workStartedAt?.trim()) return false; if (status === 'completed') return true; if (status === 'partial') { return Boolean(completedContent?.trim()) && Boolean(remainingContent?.trim()); } return false; } export function canOpenRequirementCoverageRecord( status: RequirementCoverageStatus, canEdit: boolean, ): boolean { return canEdit && status !== 'completed'; } export function appendPlanLog(plan: VersionPlan, draft: PlanLogDraft): VersionPlanLog[] { const createdAt = draft.createdAt ?? new Date().toISOString(); const log: VersionPlanLog = { ...draft, id: draft.id ?? makePlanLogId(createdAt), createdAt, }; return [log, ...(plan.logs ?? [])]; } function getPlanLogCreatedAtTime(log: VersionPlanLog): number { const time = new Date(log.createdAt).getTime(); return Number.isFinite(time) ? time : 0; } export function getPlanLogsForPlans(plans: VersionPlan[]): VersionPlanLogView[] { return plans .flatMap((plan) => (plan.logs ?? []).map((log) => ({ ...log, planId: plan.id, planTitle: plan.title, }))) .sort((a, b) => getPlanLogCreatedAtTime(b) - getPlanLogCreatedAtTime(a)); } export function startRequirementCoverageWork( plan: VersionPlan, input: RequirementCoverageWorkStartInput, ): Pick { const startedAt = input.startedAt ?? new Date().toISOString(); const existing = getRequirementCoverage(plan, input.requirementId); const nextCoverage: VersionPlanRequirementCoverage = { requirementId: input.requirementId, status: existing?.status ?? 'not_started', completedContent: existing?.completedContent, remainingContent: existing?.remainingContent, currentWorkStartedAt: startedAt, updatedAt: startedAt, updatedBy: input.updatedBy, }; return { requirementCoverage: [ nextCoverage, ...(plan.requirementCoverage ?? []).filter((item) => item.requirementId !== input.requirementId), ], }; } export function updateRequirementCoverage( plan: VersionPlan, input: RequirementCoverageUpdateInput, ): Pick { const updatedAt = input.updatedAt ?? new Date().toISOString(); const existingCoverage = plan.requirementCoverage?.find((item) => item.requirementId === input.requirementId); const workStartedAt = input.workStartedAt?.trim() || existingCoverage?.currentWorkStartedAt; const nextCoverage: VersionPlanRequirementCoverage = { requirementId: input.requirementId, status: input.status, completedContent: input.completedContent?.trim() || undefined, remainingContent: input.remainingContent?.trim() || undefined, updatedAt, updatedBy: input.updatedBy, }; const requirementCoverage = [ nextCoverage, ...(plan.requirementCoverage ?? []).filter((item) => item.requirementId !== input.requirementId), ]; const completedSet = new Set(plan.completedRequirementIds ?? []); if (input.status === 'completed') completedSet.add(input.requirementId); else completedSet.delete(input.requirementId); const detail = [ nextCoverage.completedContent ? `已完成:${nextCoverage.completedContent}` : '', nextCoverage.remainingContent ? `剩余:${nextCoverage.remainingContent}` : '', ].filter(Boolean).join('\n'); const reqLabel = [input.requirementCode, input.requirementTitle].filter(Boolean).join(' '); return { requirementCoverage, completedRequirementIds: Array.from(completedSet), logs: appendPlanLog(plan, { type: 'requirement_progress', createdAt: updatedAt, actor: input.updatedBy, title: `${reqLabel || '需求'}更新为${REQUIREMENT_COVERAGE_LABEL[input.status]}`, detail: detail || undefined, requirementId: input.requirementId, requirementCode: input.requirementCode, requirementTitle: input.requirementTitle, coverageStatus: input.status, completedContent: nextCoverage.completedContent, remainingContent: nextCoverage.remainingContent, workStartedAt, }), }; } export function startResearchDirectionWork( plan: VersionPlan, input: ResearchDirectionWorkStartInput, ): Pick { const tasks = plan.tasks ?? []; const startedAt = input.startedAt ?? new Date().toISOString(); const nextTasks = tasks.map((task) => { if (task.id !== input.taskId || task.status === 'completed') return task; return { ...task, status: 'in_progress' as const, currentWorkStartedAt: startedAt, updatedAt: startedAt, updatedBy: input.updatedBy, }; }); return { tasks: nextTasks }; } export function updateResearchDirectionProgress( plan: VersionPlan, input: ResearchDirectionProgressUpdateInput, ): Pick { const tasks = plan.tasks ?? []; const target = tasks.find((task) => task.id === input.taskId); if (!target) { return { tasks: plan.tasks, logs: plan.logs, }; } const updatedAt = input.updatedAt ?? new Date().toISOString(); const workStartedAt = input.workStartedAt?.trim() || target.currentWorkStartedAt; const nextStatus: PlanTaskStatus = input.status === 'completed' ? 'completed' : 'in_progress'; const completedContent = input.status === 'partial' ? input.completedContent?.trim() || undefined : undefined; const remainingContent = input.status === 'partial' ? input.remainingContent?.trim() || undefined : undefined; const nextTasks = tasks.map((task) => task.id === input.taskId ? { ...task, status: nextStatus, completedContent, remainingContent, currentWorkStartedAt: undefined, updatedAt, updatedBy: input.updatedBy, } : task); const detail = [ completedContent ? `已完成:${completedContent}` : '', remainingContent ? `剩余:${remainingContent}` : '', ].filter(Boolean).join('\n'); return { tasks: nextTasks, logs: appendPlanLog(plan, { type: 'research_direction_progress', createdAt: updatedAt, actor: input.updatedBy, title: `${target.title}更新为${REQUIREMENT_COVERAGE_LABEL[input.status]}`, detail: detail || undefined, directionTaskId: target.id, directionTitle: target.title, coverageStatus: input.status, completedContent, remainingContent, workStartedAt, }), }; } export function mergeVersionPlanPatch(current: VersionPlan, patch: Partial): Partial { const nextPatch: Partial = { ...patch }; const changedRequirementIds = new Set( (patch.logs ?? []) .filter((log) => log.type === 'requirement_progress' && log.requirementId) .map((log) => log.requirementId as string), ); const changedTaskIds = new Set( (patch.logs ?? []) .filter((log) => log.type === 'research_direction_progress' && log.directionTaskId) .map((log) => log.directionTaskId as string), ); if (patch.requirementCoverage) { nextPatch.requirementCoverage = mergeRequirementCoverage( current.requirementCoverage ?? [], patch.requirementCoverage, changedRequirementIds, ); nextPatch.completedRequirementIds = mergeCompletedRequirementIds( current.completedRequirementIds ?? [], nextPatch.requirementCoverage, ); } if (patch.tasks) { nextPatch.tasks = mergePlanTasks(current.tasks ?? [], patch.tasks, changedTaskIds); } if (patch.logs) { nextPatch.logs = mergePlanLogs(current.logs ?? [], patch.logs); } return nextPatch; } function mergeRequirementCoverage( currentItems: VersionPlanRequirementCoverage[], patchItems: VersionPlanRequirementCoverage[], changedRequirementIds: Set, ): VersionPlanRequirementCoverage[] { const patchIds = new Set(patchItems.map((item) => item.requirementId)); return [ ...patchItems.map((patchItem) => { const currentItem = currentItems.find((item) => item.requirementId === patchItem.requirementId); if (!currentItem || changedRequirementIds.has(patchItem.requirementId)) return patchItem; if (isNewerThan(currentItem.updatedAt, patchItem.updatedAt)) return currentItem; return { ...currentItem, ...patchItem, currentWorkStartedAt: patchItem.currentWorkStartedAt ?? currentItem.currentWorkStartedAt, }; }), ...currentItems.filter((item) => !patchIds.has(item.requirementId)), ]; } function mergePlanTasks( currentTasks: PlanTask[], patchTasks: PlanTask[], changedTaskIds: Set, ): PlanTask[] { const patchIds = new Set(patchTasks.map((item) => item.id)); return [ ...patchTasks.map((patchTask) => { const currentTask = currentTasks.find((item) => item.id === patchTask.id); if (!currentTask || changedTaskIds.has(patchTask.id)) return patchTask; if (isNewerThan(currentTask.updatedAt, patchTask.updatedAt)) return currentTask; return { ...currentTask, ...patchTask, status: maxPlanTaskStatus(currentTask.status, patchTask.status), currentWorkStartedAt: patchTask.currentWorkStartedAt ?? currentTask.currentWorkStartedAt, }; }), ...currentTasks.filter((item) => !patchIds.has(item.id)), ]; } function mergePlanLogs(currentLogs: VersionPlanLog[], patchLogs: VersionPlanLog[]): VersionPlanLog[] { const seen = new Set(); const logs: VersionPlanLog[] = []; for (const log of [...patchLogs, ...currentLogs]) { if (seen.has(log.id)) continue; seen.add(log.id); logs.push(log); } return logs; } function mergeCompletedRequirementIds( currentCompletedIds: string[], coverage: VersionPlanRequirementCoverage[], ): string[] { const completed = new Set(currentCompletedIds); for (const item of coverage) { if (item.status === 'completed') completed.add(item.requirementId); else completed.delete(item.requirementId); } return Array.from(completed); } function isNewerThan(left?: string, right?: string): boolean { if (!left || !right) return Boolean(left && !right); const leftTime = new Date(left).getTime(); const rightTime = new Date(right).getTime(); if (!Number.isFinite(leftTime) || !Number.isFinite(rightTime)) return false; return leftTime > rightTime; } function maxPlanTaskStatus(left: PlanTaskStatus, right: PlanTaskStatus): PlanTaskStatus { const rank: Record = { pending: 0, in_progress: 1, completed: 2, }; return rank[left] > rank[right] ? left : right; } function getPlanCreatedAtTime(plan: VersionPlan): number { const time = new Date(plan.createdAt).getTime(); return Number.isFinite(time) ? time : 0; } function getPlanIdTime(plan: VersionPlan): number { const match = /^plan-(\d+)$/.exec(plan.id); if (!match) return 0; const time = Number(match[1]); return Number.isFinite(time) ? time : 0; } export function sortPlansNewestFirst(plans: VersionPlan[]): VersionPlan[] { return [...plans].sort((a, b) => { const createdDiff = getPlanCreatedAtTime(b) - getPlanCreatedAtTime(a); if (createdDiff !== 0) return createdDiff; return getPlanIdTime(b) - getPlanIdTime(a); }); } export function calcPlanProgress(tasks?: PlanTask[]): number { if (!tasks || tasks.length === 0) return 0; const completed = tasks.filter((t) => t.status === 'completed').length; return Math.round((completed / tasks.length) * 100); } export function calcLinkedReqProgress(linkedIds?: string[], completedIds?: string[]): number { if (!linkedIds || linkedIds.length === 0) return 0; const done = (completedIds || []).filter((id) => linkedIds.includes(id)).length; return Math.round((done / linkedIds.length) * 100); } export function calcPlanDuration(start: string, end: string): { days: number; hours: number } { const s = new Date(start).getTime(); const e = new Date(end).getTime(); if (isNaN(s) || isNaN(e) || e < s) return { days: 0, hours: 0 }; const diffHours = (e - s) / (1000 * 60 * 60); const hours = Math.round(diffHours * 2) / 2; // 精确到0.5h const days = Math.ceil(diffHours / 24); return { days, hours }; } export function formatDuration(days: number, hours: number): string { if (hours <= 0) return '0h'; if (hours < 24) return `${hours}h`; if (hours % 24 === 0) return `${hours / 24}天`; return `${hours}h`; } export function calcTotalDuration(plans: VersionPlan[]): string { const now = new Date().toISOString(); const totalHours = plans.reduce((sum, plan) => { if (!plan.actualStartAt) return sum; return sum + calcActualElapsedHours(plan.actualStartAt, plan.completedAt ?? now); }, 0); return formatActualDuration(Math.round(totalHours * 2) / 2); } /** * 抽取每条已开始计划的 [actualStartAt, completedAt ?? now] 时间区间 * 用于双口径耗时统计(calcTwoMetrics) */ export function planIntervals(plans: VersionPlan[], now: Date = new Date()): TimeInterval[] { const out: TimeInterval[] = []; const nowIso = now.toISOString(); for (const p of plans) { if (!p.actualStartAt) continue; out.push({ start: p.actualStartAt, end: p.completedAt ?? nowIso }); } return out; }