import type { Bug } from './bug'; import type { DevTask } from './dev-task'; import { STATUS_PROGRESS, getEstimateHours } from './dev-task'; import type { TestCase } from './test-case'; import { getTestCaseEstimateHours } from './test-case'; import type { XiaobaoRiskInsight } from './xiaobao-risk-cache'; import type { VersionDailyEvidence } from './xiaobao-risk-evidence'; import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; import { WORK_HOURS, addWorkHours } from './work-hours'; export type XiaobaoRiskLevel = 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked'; export type XiaobaoConfidenceLevel = 'high' | 'medium' | 'low'; export interface XiaobaoVersionRef { id: string; name: string; status?: string; productId?: string; productName?: string; projectId?: string; projectName?: string; expectedReleaseDate?: string | null; members?: Array<{ id?: string; name: string; role?: string }>; } export interface RiskReason { key: string; title: string; detail: string; severity: 'info' | 'warning' | 'danger'; count?: number; } export interface SilentRisk { key: string; title: string; detail: string; itemId?: string; itemType?: 'dev_task' | 'test_case' | 'bug' | 'version'; } export interface XiaobaoRiskSignals { unfinishedCount: number; openBugCount: number; criticalBugCount: number; failedTestCount: number; blockedCount: number; silentRiskCount: number; daysToExpectedRelease?: number; } export interface XiaobaoVersionRisk { versionId: string; versionName: string; productId?: string; productName?: string; projectId?: string; projectName?: string; riskScore: number; riskLevel: XiaobaoRiskLevel; expectedReleaseDate: string | null; confidence: number; confidenceLevel: XiaobaoConfidenceLevel; forecastReleaseDate?: string; delayDays: number; remainingWorkHours: number; reasons: RiskReason[]; silentRisks: SilentRisk[]; dailyEvidence?: VersionDailyEvidence; signals: XiaobaoRiskSignals; currentSnapshot: XiaobaoRiskSnapshot; aiInsight?: XiaobaoRiskInsight; aiInsightUpdating?: boolean; aiInsightSource?: 'current_ai' | 'previous_ai' | 'rule'; trend: { direction: 'up' | 'down' | 'flat' | 'unknown'; delta: number; summary: string; pattern?: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown'; }; } export interface CalcXiaobaoVersionRiskInput { version: XiaobaoVersionRef; devTasks?: DevTask[]; testCases?: TestCase[]; bugs?: Bug[]; now?: Date; snapshots?: XiaobaoRiskSnapshot[]; silentRisks?: SilentRisk[]; dailyEvidence?: VersionDailyEvidence; recentActivityCount?: number; lastActivityAt?: string; } const OPEN_BUG_STATUSES = new Set(['open', 'fixing', 'fixed', 'verifying']); const TEST_DONE_STATUSES = new Set(['passed']); export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): XiaobaoVersionRisk { const now = input.now ?? new Date(); const devTasks = input.devTasks ?? []; const testCases = input.testCases ?? []; const bugs = input.bugs ?? []; const silentRisks = input.dailyEvidence?.silentRisks ?? []; const remainingDevHours = devTasks.reduce((sum, task) => { const estimate = getEstimateHours(task); const progress = STATUS_PROGRESS[task.status] ?? 0; return sum + estimate * Math.max(0, 100 - progress) / 100; }, 0); const remainingTestHours = testCases.reduce((sum, tc) => { if (TEST_DONE_STATUSES.has(tc.status)) return sum; return sum + getTestCaseEstimateHours(tc); }, 0); const openBugs = bugs.filter((bug) => OPEN_BUG_STATUSES.has(bug.status)); const remainingBugHours = openBugs.reduce((sum, bug) => sum + getBugEstimateHours(bug), 0); const remainingWorkHours = roundHours(remainingDevHours + remainingTestHours + remainingBugHours); const forecastReleaseDate = remainingWorkHours > 0 ? addWorkHours(now.toISOString(), remainingWorkHours) : undefined; const expectedRelease = parseDate(input.version.expectedReleaseDate); const forecast = parseDate(forecastReleaseDate); const delayDays = expectedRelease && forecast && forecast.getTime() > expectedRelease.getTime() ? roundDays((forecast.getTime() - expectedRelease.getTime()) / 86_400_000) : 0; const daysToExpectedRelease = expectedRelease ? roundDays((expectedRelease.getTime() - now.getTime()) / 86_400_000) : undefined; const criticalBugCount = openBugs.filter((bug) => bug.severity === 'critical' || bug.priority === 'P1').length; const failedTestCount = testCases.filter((tc) => tc.status === 'failed').length; const blockedDevCount = devTasks.filter((task) => task.isBlocked).length; const blockedTestCount = testCases.filter((tc) => tc.status === 'blocked').length; const blockedCount = blockedDevCount + blockedTestCount; const unfinishedCount = devTasks.filter((task) => task.status !== 'submitted').length + testCases.filter((tc) => tc.status !== 'passed').length + openBugs.length; const reasons: RiskReason[] = []; if (remainingWorkHours > 0) { reasons.push({ key: 'remaining_work', title: '剩余工作量', detail: `预计还剩 ${remainingWorkHours}h 工作量。`, severity: delayDays > 0 ? 'danger' : 'warning', }); } if (delayDays > 0) { reasons.push({ key: 'forecast_delay', title: '预测延期', detail: `预测发布时间晚于计划约 ${delayDays} 天。`, severity: 'danger', }); } if (criticalBugCount > 0) { reasons.push({ key: 'critical_bug', title: '关键缺陷', detail: `仍有 ${criticalBugCount} 个 P1 或致命 Bug 未关闭。`, severity: 'danger', count: criticalBugCount, }); } if (blockedCount > 0) { reasons.push({ key: 'blocked_work', title: '阻塞工作', detail: `仍有 ${blockedCount} 个开发或测试项处于阻塞。`, severity: 'danger', count: blockedCount, }); } if (failedTestCount > 0) { reasons.push({ key: 'failed_test', title: '失败用例', detail: `仍有 ${failedTestCount} 个测试用例未通过。`, severity: 'warning', count: failedTestCount, }); } if (silentRisks.length > 0) { reasons.push({ key: 'silent_risk', title: '静默风险', detail: `发现 ${silentRisks.length} 个今日缺少进展或证据的风险项。`, severity: 'warning', count: silentRisks.length, }); } const signals: XiaobaoRiskSignals = { unfinishedCount, openBugCount: openBugs.length, criticalBugCount, failedTestCount, blockedCount, silentRiskCount: silentRisks.length, daysToExpectedRelease, }; const riskScore = calcRiskScore({ delayDays, remainingWorkHours, criticalBugCount, failedTestCount, blockedCount, silentRiskCount: silentRisks.length, daysToExpectedRelease, }); const hasBlockingRisk = criticalBugCount > 0 || blockedCount > 0; const riskLevel = getRiskLevel(riskScore, delayDays, hasBlockingRisk); const confidence = calcConfidence(input, devTasks, testCases, bugs); const currentSnapshot: XiaobaoRiskSnapshot = { versionId: input.version.id, date: now.toISOString().slice(0, 10), riskScore, riskLevel, forecastReleaseDate, openBugCount: signals.openBugCount, criticalBugCount: signals.criticalBugCount, failedTestCount: signals.failedTestCount, blockedCount: signals.blockedCount, silentRiskCount: signals.silentRiskCount, confidence, createdAt: now.toISOString(), }; const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], currentSnapshot); return { versionId: input.version.id, versionName: input.version.name, productId: input.version.productId, productName: input.version.productName, projectId: input.version.projectId, projectName: input.version.projectName, riskScore, riskLevel, expectedReleaseDate: input.version.expectedReleaseDate ?? null, confidence, confidenceLevel: getConfidenceLevel(confidence), forecastReleaseDate, delayDays, remainingWorkHours, reasons, silentRisks, dailyEvidence: input.dailyEvidence, signals, currentSnapshot, trend, }; } function getBugEstimateHours(bug: Bug): number { if (typeof bug.estimateHours === 'number' && bug.estimateHours > 0) return roundHours(bug.estimateHours); if (typeof bug.aiEstimateHours === 'number' && bug.aiEstimateHours > 0) return roundHours(bug.aiEstimateHours); if (bug.severity === 'critical') return 16; if (bug.severity === 'major') return 8; return 4; } function calcRiskScore(input: { delayDays: number; remainingWorkHours: number; criticalBugCount: number; failedTestCount: number; blockedCount: number; silentRiskCount: number; daysToExpectedRelease?: number; }): number { let score = 0; if (input.delayDays > 0) score += 70 + Math.min(15, input.delayDays * 3); if (input.daysToExpectedRelease !== undefined && input.daysToExpectedRelease <= 2 && input.remainingWorkHours > 0) { score += Math.min(20, input.remainingWorkHours / WORK_HOURS.hoursPerDay * 4); } score += input.criticalBugCount * 25; score += input.blockedCount * 22; score += input.failedTestCount * 12; score += input.silentRiskCount * 8; if (input.remainingWorkHours > 0 && input.delayDays === 0) { score += Math.min(35, input.remainingWorkHours / WORK_HOURS.hoursPerDay * 5); } return clampScore(score); } function getRiskLevel(score: number, delayDays: number, hasBlockingRisk: boolean): XiaobaoRiskLevel { if (hasBlockingRisk) return 'blocked'; if (delayDays > 0 || score >= 75) return 'likely_delayed'; if (score >= 55) return 'at_risk'; if (score >= 30) return 'attention'; return 'on_track'; } function calcConfidence( input: CalcXiaobaoVersionRiskInput, devTasks: DevTask[], testCases: TestCase[], bugs: Bug[], ): number { let confidence = 100; if (!input.version.expectedReleaseDate) confidence -= 20; const workItems = [...devTasks, ...testCases, ...bugs]; const missingEstimateCount = workItems.filter((item) => !hasEstimate(item)).length; if (missingEstimateCount > 0) confidence -= Math.min(25, missingEstimateCount * 5); if (testCases.length === 0) confidence -= 15; if (!input.version.members || input.version.members.length === 0) confidence -= 10; if (input.recentActivityCount === 0 || input.lastActivityAt === undefined) confidence -= 10; if ((input.snapshots?.length ?? 0) < 2) confidence -= 10; return clampScore(confidence); } function hasEstimate(item: DevTask | TestCase | Bug): boolean { return (typeof item.estimateHours === 'number' && item.estimateHours > 0) || (typeof item.aiEstimateHours === 'number' && item.aiEstimateHours > 0); } function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel { if (confidence >= 75) return 'high'; if (confidence >= 50) return 'medium'; return 'low'; } function parseDate(iso?: string | null): Date | undefined { if (!iso) return undefined; const date = new Date(iso); return Number.isNaN(date.getTime()) ? undefined : date; } function roundHours(hours: number): number { return Math.round(hours * 2) / 2; } function roundDays(days: number): number { return Math.round(days * 10) / 10; } function clampScore(score: number): number { return Math.max(0, Math.min(100, Math.round(score))); }