diff --git a/apps/web/lib/xiaobao-risk-evidence.ts b/apps/web/lib/xiaobao-risk-evidence.ts new file mode 100644 index 0000000..b4e3d9d --- /dev/null +++ b/apps/web/lib/xiaobao-risk-evidence.ts @@ -0,0 +1,20 @@ +import type { SilentRisk } from './xiaobao-risk'; + +export interface EvidenceItem { + id: string; + title: string; + summary: string; + occurredAt: string; + actorId?: string; +} + +export interface VersionDailyEvidence { + todayDeliveries: EvidenceItem[]; + todayProgress: EvidenceItem[]; + todayRisks: EvidenceItem[]; + progressNotes: EvidenceItem[]; + needsProgressItems: EvidenceItem[]; + recentActivityCount: number; + lastActivityAt?: string; + silentRisks?: SilentRisk[]; +} diff --git a/apps/web/lib/xiaobao-risk-trend.ts b/apps/web/lib/xiaobao-risk-trend.ts new file mode 100644 index 0000000..2843cc4 --- /dev/null +++ b/apps/web/lib/xiaobao-risk-trend.ts @@ -0,0 +1,15 @@ +import type { XiaobaoRiskLevel } from './xiaobao-risk'; + +export interface XiaobaoRiskSnapshot { + versionId: string; + date: string; + riskScore: number; + riskLevel: XiaobaoRiskLevel; + forecastReleaseDate?: string; + openBugCount: number; + failedTestCount: number; + blockedCount: number; + silentRiskCount: number; + confidence: number; + createdAt: string; +} diff --git a/apps/web/lib/xiaobao-risk.test.ts b/apps/web/lib/xiaobao-risk.test.ts new file mode 100644 index 0000000..b80ccea --- /dev/null +++ b/apps/web/lib/xiaobao-risk.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { calcXiaobaoVersionRisk } from './xiaobao-risk'; +import type { DevTask } from './dev-task'; +import type { TestCase } from './test-case'; +import type { Bug } from './bug'; + +function version(patch: any = {}) { + return { + id: 'ver-1', + name: 'V1.0', + status: 'developing', + productName: 'FTB', + projectName: '项目管理', + expectedReleaseDate: '2026-07-03T10:00:00.000Z', + members: [{ name: '张三', role: 'frontend' }], + ...patch, + }; +} + +function devTask(patch: Partial = {}): DevTask { + return { + id: 'dev-1', + taskNo: 'DEV-001', + requirementId: 'req-1', + title: '实现版本风险页', + categoryId: 'frontend', + assigneeId: 'm-1', + priority: 'P1', + expectedStartAt: '2026-07-01T09:00:00.000Z', + expectedEndAt: '2026-07-03T18:00:00.000Z', + estimateHours: 40, + status: 'todo', + isBlocked: false, + createdBy: 'm-1', + createdAt: '2026-07-01T09:00:00.000Z', + updatedAt: '2026-07-01T09:00:00.000Z', + ...patch, + }; +} + +function testCase(patch: Partial = {}): TestCase { + return { + id: 'tc-1', + caseNo: 'TC-001', + versionId: 'ver-1', + title: '验收版本风险页', + categoryId: 'ui-interaction', + priority: 'P1', + assigneeId: 'm-2', + status: 'pending', + estimateHours: 16, + createdBy: 'm-2', + createdAt: '2026-07-01T09:00:00.000Z', + updatedAt: '2026-07-01T09:00:00.000Z', + ...patch, + }; +} + +function bug(patch: Partial = {}): Bug { + return { + id: 'bug-1', + bugNo: 'BUG-001', + versionId: 'ver-1', + testCaseId: 'tc-1', + title: '发布阻断缺陷', + description: '关键流程无法通过', + severity: 'critical', + priority: 'P1', + reportedBy: 'm-2', + assigneeId: 'm-1', + status: 'open', + createdAt: '2026-07-01T09:00:00.000Z', + updatedAt: '2026-07-01T09:00:00.000Z', + ...patch, + }; +} + +test('calcXiaobaoVersionRisk predicts delay when remaining work exceeds release date', () => { + const risk = calcXiaobaoVersionRisk({ + version: version(), + devTasks: [devTask()], + testCases: [testCase()], + bugs: [], + now: new Date('2026-07-02T01:00:00.000Z'), + }); + + assert.equal(risk.riskLevel, 'likely_delayed'); + assert.ok(risk.riskScore >= 70); + assert.ok(risk.delayDays > 0); + assert.ok(risk.reasons.some((reason: { key: string }) => reason.key === 'remaining_work')); +}); + +test('calcXiaobaoVersionRisk marks blocked when critical Bug / blocked dev task / failed test exist', () => { + const risk = calcXiaobaoVersionRisk({ + version: version(), + devTasks: [devTask({ isBlocked: true, blockReason: '依赖接口未完成' })], + testCases: [testCase({ status: 'failed', failReason: '核心流程失败' })], + bugs: [bug()], + now: new Date('2026-07-02T01:00:00.000Z'), + }); + + assert.equal(risk.riskLevel, 'blocked'); + assert.ok(risk.reasons.some((reason: { key: string }) => reason.key === 'critical_bug')); + assert.ok(risk.reasons.some((reason: { key: string }) => reason.key === 'blocked_work')); +}); diff --git a/apps/web/lib/xiaobao-risk.ts b/apps/web/lib/xiaobao-risk.ts new file mode 100644 index 0000000..c647a95 --- /dev/null +++ b/apps/web/lib/xiaobao-risk.ts @@ -0,0 +1,312 @@ +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 { 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; + productName?: string; + projectName?: string; + expectedReleaseDate?: string; + 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; + riskScore: number; + riskLevel: XiaobaoRiskLevel; + confidence: number; + confidenceLevel: XiaobaoConfidenceLevel; + forecastReleaseDate?: string; + delayDays: number; + remainingWorkHours: number; + reasons: RiskReason[]; + silentRisks: SilentRisk[]; + signals: XiaobaoRiskSignals; + trend: { + direction: 'up' | 'down' | 'flat' | 'unknown'; + delta: number; + summary: string; + }; +} + +export interface CalcXiaobaoVersionRiskInput { + version: XiaobaoVersionRef; + devTasks?: DevTask[]; + testCases?: TestCase[]; + bugs?: Bug[]; + now?: Date; + snapshots?: Array<{ riskScore: number; createdAt?: string; date?: string }>; + silentRisks?: SilentRisk[]; + 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.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, + }); + } + + 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 trend = calcTrend(riskScore, input.snapshots ?? []); + + return { + versionId: input.version.id, + riskScore, + riskLevel, + confidence, + confidenceLevel: getConfidenceLevel(confidence), + forecastReleaseDate, + delayDays, + remainingWorkHours, + reasons, + silentRisks, + signals, + 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 calcTrend( + currentScore: number, + snapshots: Array<{ riskScore: number; createdAt?: string; date?: string }>, +): XiaobaoVersionRisk['trend'] { + if (snapshots.length === 0) { + return { direction: 'unknown', delta: 0, summary: '暂无历史快照。' }; + } + const latest = [...snapshots].sort((a, b) => { + const aTime = parseDate(a.createdAt ?? a.date)?.getTime() ?? 0; + const bTime = parseDate(b.createdAt ?? b.date)?.getTime() ?? 0; + return bTime - aTime; + })[0]; + const delta = clampScore(currentScore) - clampScore(latest.riskScore); + const direction = Math.abs(delta) < 5 ? 'flat' : delta > 0 ? 'up' : 'down'; + const summary = direction === 'flat' + ? '风险基本持平。' + : direction === 'up' + ? `风险上升 ${delta} 分。` + : `风险下降 ${Math.abs(delta)} 分。`; + return { direction, delta, summary }; +} + +function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel { + if (confidence >= 75) return 'high'; + if (confidence >= 50) return 'medium'; + return 'low'; +} + +function parseDate(iso?: string): 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))); +}