feat(小宝预警): 增加版本风险预测引擎
This commit is contained in:
20
apps/web/lib/xiaobao-risk-evidence.ts
Normal file
20
apps/web/lib/xiaobao-risk-evidence.ts
Normal file
@@ -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[];
|
||||
}
|
||||
15
apps/web/lib/xiaobao-risk-trend.ts
Normal file
15
apps/web/lib/xiaobao-risk-trend.ts
Normal file
@@ -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;
|
||||
}
|
||||
106
apps/web/lib/xiaobao-risk.test.ts
Normal file
106
apps/web/lib/xiaobao-risk.test.ts
Normal file
@@ -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> = {}): 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> = {}): 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> = {}): 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'));
|
||||
});
|
||||
312
apps/web/lib/xiaobao-risk.ts
Normal file
312
apps/web/lib/xiaobao-risk.ts
Normal file
@@ -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<Bug['status']>(['open', 'fixing', 'fixed', 'verifying']);
|
||||
const TEST_DONE_STATUSES = new Set<TestCase['status']>(['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)));
|
||||
}
|
||||
Reference in New Issue
Block a user