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 = { critical: 4, major: 3, minor: 1.5, trivial: 0.5, }; const AI_ASSISTED_ROLE_CAPACITY_HOURS: Record = { frontend: 32, backend: 32, testing: 40, }; const REQUIREMENT_AI_SCOPE_HOURS: Record> = { 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 { const counts: Record = { 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 { const hours: Record = { 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, '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> { const currentCounts = countCurrentMembersByRole(input.currentMembers); const hours: Record = { 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>); } 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, 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)); }