feat(version): 增加参与人员AI推荐

This commit is contained in:
Script Generator
2026-06-26 17:07:09 +08:00
parent 1319677c84
commit 64491789ae
3 changed files with 964 additions and 6 deletions

View File

@@ -0,0 +1,303 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers } from './member-recommendation';
import type { DevTask } from './dev-task';
import type { Requirement } from './requirement';
import type { TestCase } from './test-case';
import { PRESET_CATEGORIES } from './task-category';
function devTask(patch: Partial<DevTask>): DevTask {
return {
id: patch.id || 'dev-1',
taskNo: patch.taskNo || 'DEV-001',
requirementId: patch.requirementId || 'req-1',
title: patch.title || 'Dev task',
categoryId: patch.categoryId || 'cat-1',
assigneeId: patch.assigneeId || 'Backend A',
priority: patch.priority || 'P2',
expectedStartAt: patch.expectedStartAt || '2026-06-24T09:00:00',
expectedEndAt: patch.expectedEndAt || '2026-06-24T18:00:00',
estimateHours: patch.estimateHours,
aiEstimateHours: patch.aiEstimateHours,
actualStartAt: patch.actualStartAt,
actualEndAt: patch.actualEndAt,
status: patch.status || 'in_progress',
isBlocked: patch.isBlocked ?? false,
createdBy: patch.createdBy || 'PM',
createdAt: patch.createdAt || '2026-06-23T09:00:00',
updatedAt: patch.updatedAt || '2026-06-24T10:00:00',
};
}
function requirement(patch: Partial<Requirement>): Requirement {
return {
id: patch.id || 'req-1',
code: patch.code || 'REQ-001',
title: patch.title || 'Requirement',
description: patch.description || 'Requirement description',
productId: patch.productId || 'prod-1',
projectId: patch.projectId || 'proj-1',
versionId: patch.versionId || 'ver-1',
sourceType: patch.sourceType || 'internal',
sourceTarget: patch.sourceTarget || 'Product',
platforms: patch.platforms || ['web'],
typeId: patch.typeId || 'type-1',
status: patch.status || 'planned',
priority: patch.priority || 'P2',
effort: patch.effort || 'M',
productOwner: patch.productOwner || 'PM',
creator: patch.creator || 'PM',
createdAt: patch.createdAt || '2026-06-23',
};
}
function testCase(patch: Partial<TestCase>): TestCase {
return {
id: patch.id || 'tc-1',
caseNo: patch.caseNo || 'TC-001',
versionId: patch.versionId || 'ver-1',
requirementId: patch.requirementId,
title: patch.title || 'Test case',
categoryId: patch.categoryId || 'cat-test-functional',
priority: patch.priority || 'P2',
assigneeId: patch.assigneeId,
status: patch.status || 'pending',
estimateHours: patch.estimateHours,
aiEstimateHours: patch.aiEstimateHours,
startedAt: patch.startedAt,
completedAt: patch.completedAt,
createdBy: patch.createdBy || 'QA',
createdAt: patch.createdAt || '2026-06-23T09:00:00',
updatedAt: patch.updatedAt || '2026-06-24T10:00:00',
};
}
test('recommendVersionMembers only recommends frontend backend and testing candidates outside current members', () => {
const groups = recommendVersionMembers({
candidates: [
{ id: 'm-1', name: 'Frontend', departmentName: '前端组' },
{ id: 'm-2', name: 'Backend', departmentName: '后端组' },
{ id: 'm-3', name: 'Tester', departmentName: '测试组' },
{ id: 'm-4', name: 'Product', departmentName: '产品部' },
{ id: 'm-5', name: 'Designer', departmentName: '设计部' },
],
currentMembers: [{ name: 'Frontend', role: 'frontend' }],
devTasks: [],
testCases: [],
bugs: [],
});
assert.deepEqual(groups.map((group) => group.role), ['backend', 'testing']);
assert.deepEqual(groups.flatMap((group) => group.items.map((item) => item.name)), ['Backend', 'Tester']);
});
test('recommendVersionMembers ranks idle people above overloaded people with deadline conflicts', () => {
const groups = recommendVersionMembers({
candidates: [
{ id: 'm-1', name: 'Backend Busy', departmentName: '后端组' },
{ id: 'm-2', name: 'Backend Idle', departmentName: '后端组' },
],
currentMembers: [],
devTasks: [
devTask({
assigneeId: 'Backend Busy',
estimateHours: 16,
expectedEndAt: '2026-06-30T18:00:00',
}),
],
testCases: [],
bugs: [],
versionDeadline: '2026-06-28T18:00:00',
});
const backendItems = groups.find((group) => group.role === 'backend')?.items ?? [];
assert.equal(backendItems[0].name, 'Backend Idle');
assert.equal(backendItems[1].metrics.deadlineConflictCount, 1);
assert.ok(backendItems[1].warnings.some((warning) => warning.includes('截止')));
});
test('recommendVersionMembers uses historical bug rate without punishing missing history', () => {
const groups = recommendVersionMembers({
candidates: [
{ id: 'm-1', name: 'Stable Dev', departmentName: '后端组' },
{ id: 'm-2', name: 'Risky Dev', departmentName: '后端组' },
{ id: 'm-3', name: 'New Dev', departmentName: '后端组' },
],
currentMembers: [],
devTasks: [],
testCases: [],
bugs: [],
historicalStats: [
{ name: 'Stable Dev', projectParticipationCount: 4, deliveredTaskCount: 20, severityWeightedBugCount: 2 },
{ name: 'Risky Dev', projectParticipationCount: 4, deliveredTaskCount: 20, severityWeightedBugCount: 14 },
],
});
const backendItems = groups.find((group) => group.role === 'backend')?.items ?? [];
const stable = backendItems.find((item) => item.name === 'Stable Dev')!;
const risky = backendItems.find((item) => item.name === 'Risky Dev')!;
const newcomer = backendItems.find((item) => item.name === 'New Dev')!;
assert.ok(stable.score > risky.score);
assert.equal(stable.confidence, 'high');
assert.equal(newcomer.confidence, 'medium');
assert.equal(newcomer.metrics.bugRate, undefined);
});
test('recommendVersionMembers limits candidates to strict AI-assisted role demand from requirements', () => {
const groups = recommendVersionMembers({
candidates: [
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
{ id: 'm-f2', name: 'Frontend B', departmentName: '前端组' },
{ id: 'm-f3', name: 'Frontend C', departmentName: '前端组' },
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
{ id: 'm-b2', name: 'Backend B', departmentName: '后端组' },
{ id: 'm-q1', name: 'QA A', departmentName: '测试组' },
{ id: 'm-q2', name: 'QA B', departmentName: '测试组' },
],
currentMembers: [{ name: 'Backend Existing', role: 'backend' }],
devTasks: [],
testCases: [],
bugs: [],
scopeRequirements: [
requirement({ id: 'req-xl', effort: 'XL' }),
requirement({ id: 'req-l', effort: 'L' }),
requirement({ id: 'req-m', effort: 'M' }),
],
scopeDevTasks: [],
scopeTestCases: [],
});
const frontend = groups.find((group) => group.role === 'frontend')!;
const backend = groups.find((group) => group.role === 'backend')!;
const testing = groups.find((group) => group.role === 'testing')!;
assert.equal(frontend.requiredCount, 2);
assert.equal(frontend.currentCount, 0);
assert.equal(frontend.missingCount, 2);
assert.equal(frontend.items.length, 2);
assert.equal(backend.requiredCount, 2);
assert.equal(backend.currentCount, 1);
assert.equal(backend.missingCount, 1);
assert.equal(backend.items.length, 1);
assert.equal(testing.requiredCount, 1);
assert.equal(testing.items.length, 1);
});
test('recommendVersionMembers omits roles without scoped task demand', () => {
const groups = recommendVersionMembers({
candidates: [
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
{ id: 'm-q1', name: 'QA A', departmentName: '测试组' },
],
currentMembers: [],
devTasks: [],
testCases: [],
bugs: [],
scopeRequirements: [],
scopeDevTasks: [
devTask({ id: 'dev-backend', categoryId: 'cat-backend-api', estimateHours: 20, assigneeId: 'Someone' }),
],
scopeTestCases: [
testCase({ id: 'tc-functional', estimateHours: 6 }),
],
taskCategories: PRESET_CATEGORIES,
});
assert.deepEqual(groups.map((group) => group.role), ['backend', 'testing']);
assert.equal(groups.find((group) => group.role === 'backend')?.items.length, 1);
assert.equal(groups.find((group) => group.role === 'testing')?.items.length, 1);
});
test('addRecommendedVersionMembers appends selected people with recommended roles once', () => {
const next = addRecommendedVersionMembers(
[
{ name: 'Product Owner', role: 'product' },
{ name: 'Backend Existing', role: 'backend' },
],
['Frontend Candidate', 'Backend Existing', 'QA Candidate'],
[
{
name: 'Frontend Candidate',
role: 'frontend',
score: 88,
confidence: 'medium',
reasons: [],
warnings: [],
metrics: {
activeTaskCount: 0,
activeBugCount: 0,
deadlineConflictCount: 0,
remainingHours: 0,
recentOvertimeHours: 0,
availableInDays: 0,
},
},
{
name: 'Backend Existing',
role: 'backend',
score: 80,
confidence: 'medium',
reasons: [],
warnings: [],
metrics: {
activeTaskCount: 0,
activeBugCount: 0,
deadlineConflictCount: 0,
remainingHours: 0,
recentOvertimeHours: 0,
availableInDays: 0,
},
},
{
name: 'QA Candidate',
role: 'testing',
score: 84,
confidence: 'medium',
reasons: [],
warnings: [],
metrics: {
activeTaskCount: 0,
activeBugCount: 0,
deadlineConflictCount: 0,
remainingHours: 0,
recentOvertimeHours: 0,
availableInDays: 0,
},
},
],
);
assert.deepEqual(next, [
{ name: 'Product Owner', role: 'product' },
{ name: 'Backend Existing', role: 'backend' },
{ name: 'Frontend Candidate', role: 'frontend' },
{ name: 'QA Candidate', role: 'testing' },
]);
});
test('getDefaultRecommendedMemberNames selects every displayed recommendation within role gaps', () => {
const groups = recommendVersionMembers({
candidates: [
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
{ id: 'm-f2', name: 'Frontend B', departmentName: '前端组' },
{ id: 'm-f3', name: 'Frontend C', departmentName: '前端组' },
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
{ id: 'm-b2', name: 'Backend B', departmentName: '后端组' },
],
currentMembers: [],
devTasks: [],
testCases: [],
bugs: [],
scopeRequirements: [
requirement({ id: 'req-xl', effort: 'XL' }),
requirement({ id: 'req-xl-2', effort: 'XL' }),
],
scopeDevTasks: [],
scopeTestCases: [],
});
assert.deepEqual(getDefaultRecommendedMemberNames(groups), ['Frontend A', 'Frontend B', 'Backend A', 'Backend B']);
});

View File

@@ -0,0 +1,400 @@
import type { Role } from './stage';
import type { VersionMember, VersionMemberCandidate } from './version-members';
import type { DevTask } from './dev-task';
import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
import type { TestCase } from './test-case';
import { TEST_CASE_STATUS_PROGRESS, getTestCaseEstimateHours } from './test-case';
import type { Bug, BugSeverity } from './bug';
import type { OvertimeRecord } from './overtime';
import type { Effort, Requirement } from './requirement';
import type { TaskCategory } from './task-category';
export type RecommendableRole = 'frontend' | 'backend' | 'testing';
export type RecommendationConfidence = 'high' | 'medium' | 'low';
export interface HistoricalMemberStats {
name: string;
projectParticipationCount?: number;
deliveredTaskCount?: number;
severityWeightedBugCount?: number;
delayRate?: number;
}
export interface MemberRecommendationMetrics {
activeTaskCount: number;
activeBugCount: number;
deadlineConflictCount: number;
remainingHours: number;
recentOvertimeHours: number;
projectParticipationCount?: number;
bugRate?: number;
availableInDays: number;
}
export interface MemberRecommendationItem {
name: string;
role: RecommendableRole;
score: number;
confidence: RecommendationConfidence;
reasons: string[];
warnings: string[];
metrics: MemberRecommendationMetrics;
}
export interface MemberRecommendationGroup {
role: RecommendableRole;
requiredCount: number;
currentCount: number;
missingCount: number;
scopeHours: number;
items: MemberRecommendationItem[];
}
export interface MemberRecommendationInput {
candidates: VersionMemberCandidate[];
currentMembers: VersionMember[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
overtimeRecords?: OvertimeRecord[];
versionDeadline?: string | null;
historicalStats?: HistoricalMemberStats[];
scopeRequirements?: Requirement[];
scopeDevTasks?: DevTask[];
scopeTestCases?: TestCase[];
taskCategories?: TaskCategory[];
now?: Date;
}
const ROLE_ORDER: RecommendableRole[] = ['frontend', 'backend', 'testing'];
const BUG_SEVERITY_WEIGHT: Record<BugSeverity, number> = {
critical: 4,
major: 3,
minor: 1.5,
trivial: 0.5,
};
const AI_ASSISTED_ROLE_CAPACITY_HOURS: Record<RecommendableRole, number> = {
frontend: 32,
backend: 32,
testing: 40,
};
const REQUIREMENT_AI_SCOPE_HOURS: Record<Effort, Record<RecommendableRole, number>> = {
S: { frontend: 4, backend: 4, testing: 3 },
M: { frontend: 8, backend: 8, testing: 5 },
L: { frontend: 16, backend: 16, testing: 8 },
XL: { frontend: 24, backend: 24, testing: 12 },
};
function clampScore(score: number): number {
return Math.max(0, Math.min(100, Math.round(score)));
}
function roundTenth(value: number): number {
return Math.round(value * 10) / 10;
}
function isTerminalDevTask(task: DevTask): boolean {
return task.status === 'submitted';
}
function isTerminalTestCase(testCase: TestCase): boolean {
return testCase.status === 'passed' || testCase.status === 'failed' || testCase.status === 'blocked';
}
function isTerminalBug(bug: Bug): boolean {
return bug.status === 'closed' || bug.status === 'rejected';
}
function getDevTaskRemainingHours(task: DevTask): number {
const estimate = getEstimateHours(task);
if (estimate <= 0) return 2;
const progress = STATUS_PROGRESS[task.status] ?? 0;
return Math.max(0, estimate * (100 - progress) / 100);
}
function getTestCaseRemainingHours(testCase: TestCase): number {
const estimate = getTestCaseEstimateHours(testCase);
if (estimate <= 0) return testCase.status === 'running' ? 0.75 : 1.5;
const progress = TEST_CASE_STATUS_PROGRESS[testCase.status] ?? 0;
return Math.max(0, estimate * (100 - progress) / 100);
}
function getBugRemainingHours(bug: Bug): number {
if (typeof bug.estimateHours === 'number' && bug.estimateHours > 0) return bug.estimateHours;
if (typeof bug.aiEstimateHours === 'number' && bug.aiEstimateHours > 0) return bug.aiEstimateHours;
return Math.max(1, BUG_SEVERITY_WEIGHT[bug.severity] ?? 1);
}
function isAfterDeadline(value: string | undefined, deadline: string | null | undefined): boolean {
if (!value || !deadline) return false;
const valueMs = new Date(value).getTime();
const deadlineMs = new Date(deadline).getTime();
return Number.isFinite(valueMs) && Number.isFinite(deadlineMs) && valueMs > deadlineMs;
}
function inferRecommendableRole(candidate: VersionMemberCandidate): RecommendableRole | undefined {
const departmentName = candidate.departmentName?.toLowerCase() ?? '';
if (!departmentName) return undefined;
if (departmentName.includes('测试') || departmentName.includes('质量') || departmentName.includes('qa')) return 'testing';
if (departmentName.includes('后端') || departmentName.includes('backend') || departmentName.includes('server')) return 'backend';
if (departmentName.includes('前端') || departmentName.includes('frontend') || departmentName.includes('web') || departmentName.includes('client')) return 'frontend';
return undefined;
}
function getConfidence(candidate: VersionMemberCandidate, stats: HistoricalMemberStats | undefined, hasCurrentActivity: boolean): RecommendationConfidence {
const hasHistory = Boolean(
stats &&
(
typeof stats.projectParticipationCount === 'number' ||
typeof stats.deliveredTaskCount === 'number' ||
typeof stats.severityWeightedBugCount === 'number' ||
typeof stats.delayRate === 'number'
),
);
if (hasHistory) return 'high';
if (candidate.departmentName || hasCurrentActivity) return 'medium';
return 'low';
}
function getHistoricalBugRate(stats: HistoricalMemberStats | undefined): number | undefined {
if (!stats?.deliveredTaskCount || stats.deliveredTaskCount <= 0) return undefined;
if (typeof stats.severityWeightedBugCount !== 'number') return undefined;
return roundTenth(stats.severityWeightedBugCount / stats.deliveredTaskCount);
}
function hasScopedDemandInput(input: MemberRecommendationInput): boolean {
return Boolean(input.scopeRequirements || input.scopeDevTasks || input.scopeTestCases);
}
function countCurrentMembersByRole(currentMembers: VersionMember[]): Record<RecommendableRole, number> {
const counts: Record<RecommendableRole, number> = { frontend: 0, backend: 0, testing: 0 };
for (const member of currentMembers) {
if (member.role === 'frontend' || member.role === 'backend' || member.role === 'testing') {
counts[member.role] += 1;
}
}
return counts;
}
function getRequirementScopeHours(requirements: Requirement[]): Record<RecommendableRole, number> {
const hours: Record<RecommendableRole, number> = { frontend: 0, backend: 0, testing: 0 };
for (const requirement of requirements) {
const scope = REQUIREMENT_AI_SCOPE_HOURS[requirement.effort] ?? REQUIREMENT_AI_SCOPE_HOURS.M;
hours.frontend += scope.frontend;
hours.backend += scope.backend;
hours.testing += scope.testing;
}
return hours;
}
function inferDevTaskScopeRole(task: DevTask, categories: TaskCategory[]): RecommendableRole | undefined {
const category = categories.find((item) => item.id === task.categoryId);
const text = `${task.categoryId} ${category?.code ?? ''} ${category?.name ?? ''}`.toLowerCase();
if (text.includes('frontend') || text.includes('前端') || text.includes('web') || text.includes('client')) return 'frontend';
if (
text.includes('backend') ||
text.includes('server') ||
text.includes('api') ||
text.includes('database') ||
text.includes('db') ||
text.includes('后端') ||
text.includes('接口') ||
text.includes('数据库')
) return 'backend';
return undefined;
}
function getScopedDevTaskHours(devTasks: DevTask[], categories: TaskCategory[]): Pick<Record<RecommendableRole, number>, 'frontend' | 'backend'> {
const hours = { frontend: 0, backend: 0 };
for (const task of devTasks) {
const estimate = Math.max(getEstimateHours(task), 2);
const role = inferDevTaskScopeRole(task, categories);
if (role === 'frontend' || role === 'backend') {
hours[role] += estimate;
} else {
hours.frontend += estimate / 2;
hours.backend += estimate / 2;
}
}
return hours;
}
function getScopedTestCaseHours(testCases: TestCase[]): number {
return testCases.reduce((sum, testCase) => sum + Math.max(getTestCaseEstimateHours(testCase), 1), 0);
}
function getRoleRecommendationDemand(input: MemberRecommendationInput, scoped: boolean): Record<RecommendableRole, Omit<MemberRecommendationGroup, 'items'>> {
const currentCounts = countCurrentMembersByRole(input.currentMembers);
const hours: Record<RecommendableRole, number> = { frontend: 0, backend: 0, testing: 0 };
const scopeRequirements = input.scopeRequirements ?? [];
const scopeDevTasks = input.scopeDevTasks ?? [];
const scopeTestCases = input.scopeTestCases ?? [];
const taskCategories = input.taskCategories ?? [];
const requirementHours = getRequirementScopeHours(scopeRequirements);
if (scopeDevTasks.length > 0) {
const devHours = getScopedDevTaskHours(scopeDevTasks, taskCategories);
hours.frontend = devHours.frontend;
hours.backend = devHours.backend;
} else if (scopeRequirements.length > 0) {
hours.frontend = requirementHours.frontend;
hours.backend = requirementHours.backend;
}
if (scopeTestCases.length > 0) {
hours.testing = getScopedTestCaseHours(scopeTestCases);
} else if (scopeRequirements.length > 0) {
hours.testing = requirementHours.testing;
}
return ROLE_ORDER.reduce((acc, role) => {
const requiredCount = scoped && hours[role] > 0
? Math.max(1, Math.ceil(hours[role] / AI_ASSISTED_ROLE_CAPACITY_HOURS[role]))
: 0;
const currentCount = currentCounts[role];
acc[role] = {
role,
requiredCount,
currentCount,
missingCount: scoped ? Math.max(0, requiredCount - currentCount) : 0,
scopeHours: roundTenth(hours[role]),
};
return acc;
}, {} as Record<RecommendableRole, Omit<MemberRecommendationGroup, 'items'>>);
}
export function recommendVersionMembers(input: MemberRecommendationInput): MemberRecommendationGroup[] {
const currentMemberNames = new Set(input.currentMembers.map((member) => member.name));
const statsByName = new Map((input.historicalStats ?? []).map((stats) => [stats.name, stats]));
const deadline = input.versionDeadline;
const scoped = hasScopedDemandInput(input);
const demandByRole = getRoleRecommendationDemand(input, scoped);
const items = input.candidates
.filter((candidate) => !currentMemberNames.has(candidate.name))
.map((candidate): MemberRecommendationItem | null => {
const role = inferRecommendableRole(candidate);
if (!role) return null;
const activeDevTasks = input.devTasks.filter((task) => task.assigneeId === candidate.name && !isTerminalDevTask(task));
const activeTestCases = input.testCases.filter((testCase) => testCase.assigneeId === candidate.name && !isTerminalTestCase(testCase));
const activeBugs = input.bugs.filter((bug) => bug.assigneeId === candidate.name && !isTerminalBug(bug));
const activeTaskCount = activeDevTasks.length + activeTestCases.length;
const activeBugCount = activeBugs.length;
const deadlineConflictCount = activeDevTasks.filter((task) => isAfterDeadline(task.expectedEndAt, deadline)).length;
const remainingHours = roundTenth(
activeDevTasks.reduce((sum, task) => sum + getDevTaskRemainingHours(task), 0) +
activeTestCases.reduce((sum, testCase) => sum + getTestCaseRemainingHours(testCase), 0) +
activeBugs.reduce((sum, bug) => sum + getBugRemainingHours(bug), 0),
);
const activeBugWeight = activeBugs.reduce((sum, bug) => sum + (BUG_SEVERITY_WEIGHT[bug.severity] ?? 1), 0);
const recentOvertimeHours = roundTenth(
(input.overtimeRecords ?? [])
.filter((record) => record.person === candidate.name)
.reduce((sum, record) => sum + record.duration, 0),
);
const stats = statsByName.get(candidate.name);
const projectParticipationCount = stats?.projectParticipationCount;
const bugRate = getHistoricalBugRate(stats);
const confidence = getConfidence(candidate, stats, activeTaskCount > 0 || activeBugCount > 0 || recentOvertimeHours > 0);
const reasons: string[] = [`部门匹配${role === 'frontend' ? '前端' : role === 'backend' ? '后端' : '测试'}角色`];
const warnings: string[] = [];
let score = 65;
if (activeTaskCount === 0 && activeBugCount === 0) {
score += 18;
reasons.push('当前无进行中任务,空闲度高');
} else {
score -= Math.min(35, activeTaskCount * 6 + activeBugCount * 5 + remainingHours * 1.2);
reasons.push(`当前 ${activeTaskCount + activeBugCount} 个进行中事项,剩余约 ${remainingHours}h`);
}
if (deadlineConflictCount > 0) {
score -= 20;
warnings.push(`${deadlineConflictCount} 个任务预计截止晚于当前版本`);
}
if (activeBugCount > 0) {
score -= Math.min(15, activeBugWeight * 3);
warnings.push(`当前有 ${activeBugCount} 个未关闭 Bug`);
}
if (typeof projectParticipationCount === 'number' && projectParticipationCount > 0) {
score += Math.min(15, projectParticipationCount * 3);
reasons.push(`历史参与项目 ${projectParticipationCount}`);
}
if (typeof bugRate === 'number') {
score -= Math.min(22, bugRate * 30);
if (bugRate <= 0.2) reasons.push('历史 Bug 率较低');
if (bugRate >= 0.5) warnings.push('历史 Bug 率偏高');
}
if (typeof stats?.delayRate === 'number' && stats.delayRate > 0.2) {
score -= Math.min(12, stats.delayRate * 30);
warnings.push('历史延期率偏高');
}
if (recentOvertimeHours >= 8) {
score -= 8;
warnings.push('近期加班较多');
}
const metrics: MemberRecommendationMetrics = {
activeTaskCount,
activeBugCount,
deadlineConflictCount,
remainingHours,
recentOvertimeHours,
projectParticipationCount,
bugRate,
availableInDays: remainingHours > 0 ? Math.ceil(remainingHours / 8) : 0,
};
return {
name: candidate.name,
role,
score: clampScore(score),
confidence,
reasons,
warnings,
metrics,
};
})
.filter((item): item is MemberRecommendationItem => Boolean(item));
return ROLE_ORDER
.map((role) => {
const roleItems = items
.filter((item) => item.role === role)
.sort((a, b) => b.score - a.score || a.metrics.remainingHours - b.metrics.remainingHours || a.name.localeCompare(b.name));
const demand = demandByRole[role];
const missingCount = scoped ? demand.missingCount : roleItems.length;
return {
...demand,
requiredCount: scoped ? demand.requiredCount : demand.currentCount + roleItems.length,
missingCount,
items: roleItems.slice(0, missingCount),
};
})
.filter((group) => group.items.length > 0);
}
export function addRecommendedVersionMembers(
currentMembers: VersionMember[],
selectedNames: Iterable<string>,
recommendations: MemberRecommendationItem[],
): VersionMember[] {
const existingNames = new Set(currentMembers.map((member) => member.name));
const selectedNameSet = new Set(selectedNames);
const additions = recommendations
.filter((item) => selectedNameSet.has(item.name) && !existingNames.has(item.name))
.map((item) => ({ name: item.name, role: item.role }));
return [...currentMembers, ...additions];
}
export function getDefaultRecommendedMemberNames(groups: MemberRecommendationGroup[]): string[] {
return groups.flatMap((group) => group.items.map((item) => item.name));
}