Files
ftb-project-management/apps/web/lib/version-plan.ts
2e595c7e72
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
refactor(data): 收口关系表运行时数据源
- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读
- 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移
- 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-09 14:59:49 +08:00

649 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ProductPlanKind, string> = {
design: '设计方案',
review: '方案评审',
};
export const PRODUCT_PLAN_REVIEW_RESULT_LABEL: Record<ProductPlanReviewResult, string> = {
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<Pick<PlanTask, 'title'>>): 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<RequirementCoverageStatus, 'partial' | 'completed'>;
completedContent?: string;
remainingContent?: string;
workStartedAt?: string;
updatedAt?: string;
updatedBy: string;
}
export type PlanLogDraft = Omit<VersionPlanLog, 'id' | 'createdAt'> & {
id?: string;
createdAt?: string;
};
export type VersionPlanLogView = VersionPlanLog & {
planId: string;
planTitle: string;
};
export const REQUIREMENT_COVERAGE_LABEL: Record<RequirementCoverageStatus, string> = {
not_started: '未开始',
partial: '部分完成',
completed: '完全完成',
};
export function getResearchDirectionStatus(task: Pick<PlanTask, 'status'>): 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<VersionPlan, 'requirementCoverage'> {
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<VersionPlan, 'requirementCoverage' | 'completedRequirementIds' | 'logs'> {
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<VersionPlan, 'tasks'> {
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<VersionPlan, 'tasks' | 'logs'> {
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<VersionPlan>): Partial<VersionPlan> {
const nextPatch: Partial<VersionPlan> = { ...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<string>,
): 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<string>,
): 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<string>();
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<PlanTaskStatus, number> = {
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;
}