import type { AgentDecomposeTarget } from '@ftb/shared'; 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' | '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; } export interface VersionPlanRequirementCoverage { requirementId: string; status: RequirementCoverageStatus; completedContent?: string; remainingContent?: 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; aiTarget?: AgentDecomposeTarget; aiStatus?: AiDecomposeLogStatus; } export interface RequirementCoverageUpdateInput { requirementId: string; status: RequirementCoverageStatus; completedContent?: string; remainingContent?: string; updatedAt?: string; updatedBy: string; requirementCode?: string; requirementTitle?: 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 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, ): boolean { 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 updateRequirementCoverage( plan: VersionPlan, input: RequirementCoverageUpdateInput, ): Pick { const updatedAt = input.updatedAt ?? new Date().toISOString(); 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, }), }; } 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 today = new Date().toISOString().slice(0, 10); const activePlans = plans.filter((p) => p.status !== 'pending' || p.startTime <= today); if (activePlans.length === 0) return '0天'; const intervals = activePlans.map((p) => { const start = new Date(p.startTime).getTime(); const end = p.completedAt ? new Date(p.completedAt).getTime() : p.startTime <= today ? new Date(today).getTime() : new Date(p.startTime).getTime(); return { start, end }; }).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start); if (intervals.length === 0) return '0天'; let totalMs = 0; let currentStart = intervals[0].start; let currentEnd = intervals[0].end; for (let i = 1; i < intervals.length; i++) { if (intervals[i].start <= currentEnd) { currentEnd = Math.max(currentEnd, intervals[i].end); } else { totalMs += currentEnd - currentStart; currentStart = intervals[i].start; currentEnd = intervals[i].end; } } totalMs += currentEnd - currentStart; const totalDays = Math.ceil(totalMs / (1000 * 60 * 60 * 24)); return `${totalDays}天`; } import type { TimeInterval } from './work-hours'; /** * 抽取每条已开始计划的 [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; }