diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index f1a294d..4eb57fb 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, UserPlus, X } from 'lucide-react'; +import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore'; @@ -23,6 +23,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useBugStore } from '@/stores/useBugStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; +import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task'; import { hasPermission } from '@/lib/permissions'; import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours'; @@ -30,6 +31,7 @@ import { formatDateTime } from '@/lib/format'; import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector'; import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview'; import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members'; +import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation'; function formatOverviewDateTime(value?: string | null): string { if (!value) return '-'; @@ -43,6 +45,18 @@ const BUG_SEVERITY_SEGMENTS = [ { key: 'trivial', label: '轻微', color: 'bg-zinc-400' }, ] as const; +const RECOMMENDATION_ROLE_LABEL: Record = { + frontend: '前端', + backend: '后端', + testing: '测试', +}; + +const RECOMMENDATION_CONFIDENCE_LABEL = { + high: '高置信', + medium: '中置信', + low: '低置信', +} as const; + const TABS = [ { key: 'overview', label: '概览', permission: null as string | null }, { key: 'requirements', label: '关联需求', permission: 'version.req:view' }, @@ -66,7 +80,8 @@ export default function VersionDetailPage() { const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore(); const { bugs, fetchBugs, deleteBug } = useBugStore(); const user = useAuthStore((s) => s.user); - const { departments, members: allMembers, roles } = useMemberStore(); + const { departments, members: allMembers, roles, fetchMembers } = useMemberStore(); + const { categories: taskCategories, fetchCategories } = useTaskCategoryStore(); const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]); const memberCandidates = useMemo( () => allMembers.map((member) => ({ @@ -87,6 +102,8 @@ export default function VersionDetailPage() { } }, [visibleTabs, activeTab]); const [showMemberModal, setShowMemberModal] = useState(false); + const [showRecommendModal, setShowRecommendModal] = useState(false); + const [recommendationDataReady, setRecommendationDataReady] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]); @@ -95,6 +112,14 @@ export default function VersionDetailPage() { useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]); useEffect(() => { fetchTestCases(); }, [fetchTestCases]); useEffect(() => { fetchBugs(); }, [fetchBugs]); + useEffect(() => { + let active = true; + setRecommendationDataReady(false); + Promise.all([Promise.resolve(fetchMembers()), Promise.resolve(fetchCategories())]).finally(() => { + if (active) setRecommendationDataReady(true); + }); + return () => { active = false; }; + }, [fetchMembers, fetchCategories]); const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]); @@ -144,6 +169,36 @@ export default function VersionDetailPage() { const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress); const healthLevel = getHealthLevel(healthScore); const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members); + const recommendationVersionReqs = requirements.filter((requirement) => requirement.versionId === version.id); + const recommendationVersionReqIds = new Set(recommendationVersionReqs.map((requirement) => requirement.id)); + const recommendationVersionDevTasks = devTasks.filter((task) => recommendationVersionReqIds.has(task.requirementId)); + const recommendationVersionTestCases = testCases.filter((testCase) => testCase.versionId === version.id); + const currentSystemParticipation = new Map(); + overview.forEach((product) => { + product.versions.forEach((item) => { + const uniqueNames = new Set((item.members ?? []).map((member) => member.name)); + uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1)); + }); + }); + const memberRecommendationGroups = recommendVersionMembers({ + candidates: memberCandidates, + currentMembers: version.members ?? [], + devTasks, + testCases, + bugs, + overtimeRecords: records, + versionDeadline: version.expectedReleaseDate, + scopeRequirements: recommendationVersionReqs, + scopeDevTasks: recommendationVersionDevTasks, + scopeTestCases: recommendationVersionTestCases, + taskCategories, + historicalStats: memberCandidates.flatMap((member) => { + const projectParticipationCount = currentSystemParticipation.get(member.name); + return typeof projectParticipationCount === 'number' + ? [{ name: member.name, projectParticipationCount }] + : []; + }), + }); const renderActions = () => { const buttons: { label: string; action: () => void; danger?: boolean }[] = []; @@ -455,11 +510,20 @@ export default function VersionDetailPage() { })()}
-
+
参与人员
- +
+ + +
{(() => { const membersList = version.members ?? []; @@ -884,6 +948,20 @@ export default function VersionDetailPage() { )}
+ {showRecommendModal && ( + { + const recommendations = memberRecommendationGroups.flatMap((group) => group.items); + const newMembers = addRecommendedVersionMembers(version.members ?? [], selectedNames, recommendations); + updateVersion(version.productId, version.id, { members: newMembers }); + setShowRecommendModal(false); + }} + onClose={() => setShowRecommendModal(false)} + /> + )} + {/* 参与人员设置弹窗 */} {showMemberModal && ( = 80) return 'bg-emerald-50 text-emerald-700 border-emerald-100'; + if (score >= 65) return 'bg-blue-50 text-blue-700 border-blue-100'; + if (score >= 50) return 'bg-amber-50 text-amber-700 border-amber-100'; + return 'bg-red-50 text-red-700 border-red-100'; +} + +function getRecommendationBarClass(score: number): string { + if (score >= 80) return 'bg-emerald-500'; + if (score >= 65) return 'bg-blue-500'; + if (score >= 50) return 'bg-amber-500'; + return 'bg-red-500'; +} + +function getInitialRecommendationSelection(groups: MemberRecommendationGroup[]): Set { + return new Set(getDefaultRecommendedMemberNames(groups)); +} + +function MemberRecommendationModal({ groups, members, onAdd, onClose }: { + groups: MemberRecommendationGroup[]; + members: { role: Role; name: string }[]; + onAdd: (selectedNames: string[]) => void; + onClose: () => void; +}) { + const allItems = groups.flatMap((group) => group.items); + const [selectedNames, setSelectedNames] = useState>(() => getInitialRecommendationSelection(groups)); + const hasHistoricalSignal = allItems.some((item) => item.confidence === 'high'); + + const toggleName = (name: string) => { + setSelectedNames((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }; + + const handleAdd = () => { + if (selectedNames.size === 0) return; + onAdd(Array.from(selectedNames)); + }; + + return ( +
+
e.stopPropagation()}> +
+
+
+

AI推荐参与人员

+ {!hasHistoricalSignal && allItems.length > 0 && ( + 历史数据待导入 + )} +
+

+ 当前 {members.length} 人已加入,推荐 {allItems.length} 人,已选 {selectedNames.size} 人 +

+
+ +
+ +
+ {allItems.length === 0 ? ( +
+ +
暂无可推荐人员
+
当前推荐缺口已满足,或暂无符合前端、后端、测试部门且未加入版本的候选人。
+
+ ) : ( +
+ {groups.map((group) => ( +
+
+
+ {RECOMMENDATION_ROLE_LABEL[group.role]} + + 建议 {group.requiredCount} / 已有 {group.currentCount} / 缺 {group.missingCount} + +
+ 范围 {formatRecommendationHours(group.scopeHours)} +
+
+ {group.items.map((item) => { + const selected = selectedNames.has(item.name); + const workloadCount = item.metrics.activeTaskCount + item.metrics.activeBugCount; + const stats = [ + { label: '剩余', value: formatRecommendationHours(item.metrics.remainingHours) }, + { label: '可接入', value: formatAvailableDays(item.metrics.availableInDays) }, + { label: '事项', value: `${workloadCount}项` }, + { label: '加班', value: formatRecommendationHours(item.metrics.recentOvertimeHours) }, + ]; + + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ +
+ +
+ + +
+
+
+
+ ); +} + function MemberSettingModal({ members, allMembers, onSave, onClose }: { members: { role: Role; name: string }[]; allMembers: { id: string; name: string; departmentName?: string }[]; diff --git a/apps/web/lib/member-recommendation.test.ts b/apps/web/lib/member-recommendation.test.ts new file mode 100644 index 0000000..b84d897 --- /dev/null +++ b/apps/web/lib/member-recommendation.test.ts @@ -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 { + 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 { + 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 { + 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']); +}); diff --git a/apps/web/lib/member-recommendation.ts b/apps/web/lib/member-recommendation.ts new file mode 100644 index 0000000..1c2f3f6 --- /dev/null +++ b/apps/web/lib/member-recommendation.ts @@ -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 = { + 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)); +}