From 42ec705298624d6e2d907527bcf934333de32ffd Mon Sep 17 00:00:00 2001 From: donpat1to Date: Tue, 14 Oct 2025 10:07:57 +0200 Subject: [PATCH] seperated files for scheduling logic --- .../src/pages/ShiftPlans/ShiftPlanView.tsx | 3 +- .../src/services/scheduling/dataAdapter.ts | 84 ++++++++ frontend/src/services/scheduling/index.ts | 6 + .../services/scheduling/repairFunctions.ts | 92 +++++++++ .../src/services/scheduling/shiftScheduler.ts | 191 ++++++++++++++++++ frontend/src/services/scheduling/types.ts | 48 +++++ frontend/src/services/scheduling/utils.ts | 58 ++++++ .../src/services/shiftAssignmentService.ts | 87 ++++---- 8 files changed, 530 insertions(+), 39 deletions(-) create mode 100644 frontend/src/services/scheduling/dataAdapter.ts create mode 100644 frontend/src/services/scheduling/index.ts create mode 100644 frontend/src/services/scheduling/repairFunctions.ts create mode 100644 frontend/src/services/scheduling/shiftScheduler.ts create mode 100644 frontend/src/services/scheduling/types.ts create mode 100644 frontend/src/services/scheduling/utils.ts diff --git a/frontend/src/pages/ShiftPlans/ShiftPlanView.tsx b/frontend/src/pages/ShiftPlans/ShiftPlanView.tsx index 6575aec..48dd01c 100644 --- a/frontend/src/pages/ShiftPlans/ShiftPlanView.tsx +++ b/frontend/src/pages/ShiftPlans/ShiftPlanView.tsx @@ -4,7 +4,8 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; import { shiftPlanService } from '../../services/shiftPlanService'; import { employeeService } from '../../services/employeeService'; -import { shiftAssignmentService, ShiftAssignmentService, AssignmentResult } from '../../services/shiftAssignmentService'; +import { shiftAssignmentService, ShiftAssignmentService } from '../../services/shiftAssignmentService'; +import { AssignmentResult } from '../../services/scheduling/types'; import { ShiftPlan, TimeSlot } from '../../models/ShiftPlan'; import { Employee, EmployeeAvailability } from '../../models/Employee'; import { useNotification } from '../../contexts/NotificationContext'; diff --git a/frontend/src/services/scheduling/dataAdapter.ts b/frontend/src/services/scheduling/dataAdapter.ts new file mode 100644 index 0000000..84e8661 --- /dev/null +++ b/frontend/src/services/scheduling/dataAdapter.ts @@ -0,0 +1,84 @@ +// frontend/src/services/scheduling/dataAdapter.ts +import { Employee, EmployeeAvailability } from '../../models/Employee'; +import { ScheduledShift } from '../../models/ShiftPlan'; +import { SchedulingEmployee, SchedulingShift } from './types'; + +export function transformToSchedulingData( + employees: Employee[], + scheduledShifts: ScheduledShift[], + availabilities: EmployeeAvailability[] +): { + schedulingEmployees: SchedulingEmployee[]; + schedulingShifts: SchedulingShift[]; + managerShifts: string[]; +} { + + // Create employee availability map + const availabilityMap = new Map>(); + + availabilities.forEach(avail => { + if (!availabilityMap.has(avail.employeeId)) { + availabilityMap.set(avail.employeeId, new Map()); + } + + // Create a unique key for each shift pattern (dayOfWeek + timeSlotId) + const shiftKey = `${avail.dayOfWeek}-${avail.timeSlotId}`; + availabilityMap.get(avail.employeeId)!.set(shiftKey, avail.preferenceLevel); + }); + + // Transform employees + const schedulingEmployees: SchedulingEmployee[] = employees.map(emp => { + // Map roles + let role: 'manager' | 'erfahren' | 'neu'; + if (emp.role === 'admin') role = 'manager'; + else if (emp.employeeType === 'experienced') role = 'erfahren'; + else role = 'neu'; + + // Map contract + const contract = emp.contractType === 'small' ? 1 : 2; + + return { + id: emp.id, + name: emp.name, + role, + contract, + availability: availabilityMap.get(emp.id) || new Map(), + assignedCount: 0, + originalData: emp + }; + }); + + // Transform shifts and identify manager shifts + const schedulingShifts: SchedulingShift[] = scheduledShifts.map(scheduledShift => ({ + id: scheduledShift.id, + requiredEmployees: scheduledShift.requiredEmployees, + originalData: scheduledShift + })); + + // Identify manager shifts (shifts where manager has availability 1 or 2) + const manager = schedulingEmployees.find(emp => emp.role === 'manager'); + const managerShifts: string[] = []; + + if (manager) { + scheduledShifts.forEach(scheduledShift => { + const dayOfWeek = getDayOfWeek(scheduledShift.date); + const shiftKey = `${dayOfWeek}-${scheduledShift.timeSlotId}`; + const preference = manager.availability.get(shiftKey); + + if (preference === 1 || preference === 2) { + managerShifts.push(scheduledShift.id); + } + }); + } + + return { + schedulingEmployees, + schedulingShifts, + managerShifts + }; +} + +function getDayOfWeek(dateString: string): number { + const date = new Date(dateString); + return date.getDay() === 0 ? 7 : date.getDay(); +} \ No newline at end of file diff --git a/frontend/src/services/scheduling/index.ts b/frontend/src/services/scheduling/index.ts new file mode 100644 index 0000000..e407b42 --- /dev/null +++ b/frontend/src/services/scheduling/index.ts @@ -0,0 +1,6 @@ +// frontend/src/services/scheduling/index.ts +export * from './types'; +export * from './utils'; +export * from './repairFunctions'; +export * from './shiftScheduler'; +export * from './dataAdapter'; \ No newline at end of file diff --git a/frontend/src/services/scheduling/repairFunctions.ts b/frontend/src/services/scheduling/repairFunctions.ts new file mode 100644 index 0000000..bc44142 --- /dev/null +++ b/frontend/src/services/scheduling/repairFunctions.ts @@ -0,0 +1,92 @@ +// frontend/src/services/scheduling/repairFunctions.ts +import { SchedulingEmployee, SchedulingShift, Assignment } from './types'; +import { canAssign, assignEmployee, hasErfahrener } from './utils'; + +export function attemptRepairMoveErfahrener( + targetShiftId: string, + assignments: Assignment, + employees: Map, + allShifts: SchedulingShift[] +): boolean { + + for (const shift of allShifts) { + const currentAssignment = assignments[shift.id] || []; + + // Skip if this shift doesn't have multiple employees + if (currentAssignment.length <= 1) continue; + + for (const empId of currentAssignment) { + const emp = employees.get(empId); + if (!emp || emp.role !== 'erfahren') continue; + + // Check if employee can be moved to target shift + if (canAssign(emp, targetShiftId)) { + // Remove from current shift + assignments[shift.id] = currentAssignment.filter(id => id !== empId); + emp.assignedCount--; + + // Add to target shift + assignEmployee(emp, targetShiftId, assignments); + console.log(`🔧 Repaired: Moved erfahrener ${emp.id} to shift ${targetShiftId}`); + return true; + } + } + } + + return false; +} + +export function attemptSwapBringErfahrener( + targetShiftId: string, + assignments: Assignment, + employees: Map, + allShifts: SchedulingShift[] +): boolean { + const targetAssignment = assignments[targetShiftId] || []; + + for (const shift of allShifts) { + if (shift.id === targetShiftId) continue; + + const currentAssignment = assignments[shift.id] || []; + + for (const erfahrenerId of currentAssignment) { + const erfahrener = employees.get(erfahrenerId); + if (!erfahrener || erfahrener.role !== 'erfahren') continue; + + // Check if erfahrener can go to target shift + if (!canAssign(erfahrener, targetShiftId)) continue; + + // Find someone from target shift who can swap + for (const targetEmpId of targetAssignment) { + const targetEmp = employees.get(targetEmpId); + if (!targetEmp) continue; + + // Check if target employee can go to the other shift + if (canAssign(targetEmp, shift.id)) { + // Perform swap + assignments[shift.id] = currentAssignment.filter(id => id !== erfahrenerId).concat(targetEmpId); + assignments[targetShiftId] = targetAssignment.filter(id => id !== targetEmpId).concat(erfahrenerId); + + erfahrener.assignedCount++; + targetEmp.assignedCount--; + + console.log(`🔄 Swapped: ${erfahrener.id} <-> ${targetEmp.id}`); + return true; + } + } + } + } + + return false; +} + +export function attemptComplexRepairForManagerShift( + targetShiftId: string, + assignments: Assignment, + employees: Map, + allShifts: SchedulingShift[] +): boolean { + // Try multiple repair strategies + return attemptSwapBringErfahrener(targetShiftId, assignments, employees, allShifts) || + attemptRepairMoveErfahrener(targetShiftId, assignments, employees, allShifts); +} \ No newline at end of file diff --git a/frontend/src/services/scheduling/shiftScheduler.ts b/frontend/src/services/scheduling/shiftScheduler.ts new file mode 100644 index 0000000..25acc0f --- /dev/null +++ b/frontend/src/services/scheduling/shiftScheduler.ts @@ -0,0 +1,191 @@ +// frontend/src/services/scheduling/shiftScheduler.ts +import { + SchedulingEmployee, + SchedulingShift, + Assignment, + SchedulingResult, + SchedulingConstraints +} from './types'; +import { + canAssign, + candidateScore, + onlyNeuAssigned, + assignEmployee, + hasErfahrener, + respectsNewRuleIfAdded +} from './utils'; +import { + attemptRepairMoveErfahrener, + attemptSwapBringErfahrener, + attemptComplexRepairForManagerShift +} from './repairFunctions'; + +export function scheduleWithManager( + shifts: SchedulingShift[], + employees: SchedulingEmployee[], + managerShifts: string[], // shift IDs where manager should be assigned + constraints: SchedulingConstraints +): SchedulingResult { + + const assignments: Assignment = {}; + const violations: string[] = []; + const employeeMap = new Map(employees.map(emp => [emp.id, emp])); + + // Initialize assignments + shifts.forEach(shift => { + assignments[shift.id] = []; + }); + + // Find manager + const manager = employees.find(emp => emp.role === 'manager'); + + // Helper function to find best candidate + function findBestCandidate(candidates: SchedulingEmployee[], shiftId: string): SchedulingEmployee | null { + if (candidates.length === 0) return null; + + return candidates.reduce((best, current) => { + const bestScore = candidateScore(best, shiftId); + const currentScore = candidateScore(current, shiftId); + + // Compare arrays lexicographically + for (let i = 0; i < bestScore.length; i++) { + if (currentScore[i] < bestScore[i]) return current; + if (currentScore[i] > bestScore[i]) return best; + } + return best; + }); + } + + // PHASE A: Regular employee scheduling (IGNORE ManagerShifts) + const nonManagerShifts = shifts.filter(shift => !managerShifts.includes(shift.id)); + + // 1) Basic coverage: at least 1 person per shift (prefer erfahrene) + for (const shift of nonManagerShifts) { + const candidates = employees.filter(emp => + emp.role !== 'manager' && + canAssign(emp, shift.id) && + respectsNewRuleIfAdded(emp, shift.id, assignments, employeeMap) + ); + + if (candidates.length === 0) { + violations.push(`No available employees for shift ${shift.id}`); + continue; + } + + // Prefer erfahrene candidates + const erfahrenCandidates = candidates.filter(emp => emp.role === 'erfahren'); + const bestCandidate = findBestCandidate( + erfahrenCandidates.length > 0 ? erfahrenCandidates : candidates, + shift.id + ); + + if (bestCandidate) { + assignEmployee(bestCandidate, shift.id, assignments); + } + } + + // 2) Ensure: no neu working alone (Phase A) + if (constraints.enforceNoTraineeAlone) { + for (const shift of nonManagerShifts) { + if (onlyNeuAssigned(assignments[shift.id], employeeMap)) { + const erfahrenCandidates = employees.filter(emp => + emp.role === 'erfahren' && + canAssign(emp, shift.id) + ); + + if (erfahrenCandidates.length > 0) { + const bestCandidate = findBestCandidate(erfahrenCandidates, shift.id); + if (bestCandidate) { + assignEmployee(bestCandidate, shift.id, assignments); + } + } else { + // Try repair + if (!attemptRepairMoveErfahrener(shift.id, assignments, employeeMap, shifts)) { + violations.push(`Cannot prevent neu-alone in shift ${shift.id}`); + } + } + } + } + } + + // 3) Goal: up to required employees per shift + for (const shift of nonManagerShifts) { + while (assignments[shift.id].length < shift.requiredEmployees) { + const candidates = employees.filter(emp => + emp.role !== 'manager' && + canAssign(emp, shift.id) && + !assignments[shift.id].includes(emp.id) && + respectsNewRuleIfAdded(emp, shift.id, assignments, employeeMap) + ); + + if (candidates.length === 0) break; + + const bestCandidate = findBestCandidate(candidates, shift.id); + if (bestCandidate) { + assignEmployee(bestCandidate, shift.id, assignments); + } else { + break; + } + } + } + + // PHASE B: Manager shifts + if (manager) { + for (const shiftId of managerShifts) { + const shift = shifts.find(s => s.id === shiftId); + if (!shift) continue; + + // Assign manager to his chosen shifts + if (!assignments[shiftId].includes(manager.id)) { + if (manager.availability.get(shiftId) === 3) { + violations.push(`Manager assigned to shift he marked unavailable: ${shiftId}`); + } + assignEmployee(manager, shiftId, assignments); + } + + // Rule: if manager present, MUST be at least one ERFAHRENER in same shift + if (!hasErfahrener(assignments[shiftId], employeeMap)) { + const erfahrenCandidates = employees.filter(emp => + emp.role === 'erfahren' && + canAssign(emp, shiftId) + ); + + if (erfahrenCandidates.length > 0) { + const bestCandidate = findBestCandidate(erfahrenCandidates, shiftId); + if (bestCandidate) { + assignEmployee(bestCandidate, shiftId, assignments); + continue; + } + } + + // Try repairs + if (!attemptSwapBringErfahrener(shiftId, assignments, employeeMap, shifts) && + !attemptComplexRepairForManagerShift(shiftId, assignments, employeeMap, shifts)) { + violations.push(`Cannot satisfy manager+erfahren requirement for shift ${shiftId}`); + } + } + } + } + + // FINAL: Validate constraints + for (const shift of shifts) { + if (assignments[shift.id].length === 0) { + violations.push(`Empty shift after scheduling: ${shift.id}`); + } + if (constraints.enforceNoTraineeAlone && onlyNeuAssigned(assignments[shift.id], employeeMap)) { + violations.push(`Neu alone after full scheduling: ${shift.id}`); + } + } + + for (const emp of employees) { + if (emp.role !== 'manager' && emp.assignedCount > emp.contract) { + violations.push(`Contract exceeded for employee: ${emp.id}`); + } + } + + return { + assignments, + violations, + success: violations.length === 0 + }; +} \ No newline at end of file diff --git a/frontend/src/services/scheduling/types.ts b/frontend/src/services/scheduling/types.ts new file mode 100644 index 0000000..0c2f417 --- /dev/null +++ b/frontend/src/services/scheduling/types.ts @@ -0,0 +1,48 @@ +// frontend/src/services/scheduling/types.ts +import { ScheduledShift } from "../../models/ShiftPlan"; + +export interface SchedulingEmployee { + id: string; + name: string; + role: 'manager' | 'erfahren' | 'neu'; + contract: number; // max assignments per week + availability: Map; // shiftId -> preferenceLevel (1,2,3) + assignedCount: number; + originalData: any; // reference to original employee data +} + +export interface SchedulingShift { + id: string; + requiredEmployees: number; + isManagerShift?: boolean; + originalData: any; // reference to original shift data +} + +export interface Assignment { + [shiftId: string]: string[]; // employee IDs +} + +export interface SchedulingResult { + assignments: Assignment; + violations: string[]; + success: boolean; +} + +export interface SchedulingConstraints { + enforceNoTraineeAlone: boolean; + enforceExperiencedWithChef: boolean; + maxRepairAttempts: number; +} + +export interface AssignmentResult { + assignments: { [shiftId: string]: string[] }; + violations: string[]; + success: boolean; + pattern: WeeklyPattern; +} + +export interface WeeklyPattern { + weekShifts: ScheduledShift[]; + assignments: { [shiftId: string]: string[] }; + weekNumber: number; +} \ No newline at end of file diff --git a/frontend/src/services/scheduling/utils.ts b/frontend/src/services/scheduling/utils.ts new file mode 100644 index 0000000..efbc50c --- /dev/null +++ b/frontend/src/services/scheduling/utils.ts @@ -0,0 +1,58 @@ +// frontend/src/services/scheduling/utils.ts +import { SchedulingEmployee, SchedulingShift, Assignment } from './types'; + +export function canAssign(emp: SchedulingEmployee, shiftId: string): boolean { + if (emp.availability.get(shiftId) === 3) return false; + if (emp.role === 'manager') return true; + return emp.assignedCount < emp.contract; +} + +export function candidateScore(emp: SchedulingEmployee, shiftId: string): [number, number, number] { + const availability = emp.availability.get(shiftId) || 3; + const rolePriority = emp.role === 'erfahren' ? 0 : 1; + return [availability, emp.assignedCount, rolePriority]; +} + +export function rolePriority(role: string): number { + return role === 'erfahren' ? 0 : 1; +} + +export function onlyNeuAssigned(assignment: string[], employees: Map): boolean { + if (assignment.length === 0) return false; + return assignment.every(empId => { + const emp = employees.get(empId); + return emp?.role === 'neu'; + }); +} + +export function assignEmployee(emp: SchedulingEmployee, shiftId: string, assignments: Assignment): void { + if (!assignments[shiftId]) { + assignments[shiftId] = []; + } + assignments[shiftId].push(emp.id); + emp.assignedCount++; +} + +export function hasErfahrener(assignment: string[], employees: Map): boolean { + return assignment.some(empId => { + const emp = employees.get(empId); + return emp?.role === 'erfahren'; + }); +} + +export function respectsNewRuleIfAdded( + emp: SchedulingEmployee, + shiftId: string, + assignments: Assignment, + employees: Map +): boolean { + const currentAssignment = assignments[shiftId] || []; + + if (emp.role === 'neu') { + // Neu employee can only be added if there's already an erfahrener + return hasErfahrener(currentAssignment, employees); + } + + // Erfahren employees are always allowed + return true; +} \ No newline at end of file diff --git a/frontend/src/services/shiftAssignmentService.ts b/frontend/src/services/shiftAssignmentService.ts index 89a3e5b..74ed8ff 100644 --- a/frontend/src/services/shiftAssignmentService.ts +++ b/frontend/src/services/shiftAssignmentService.ts @@ -2,22 +2,12 @@ import { ShiftPlan, ScheduledShift } from '../models/ShiftPlan'; import { Employee, EmployeeAvailability } from '../models/Employee'; import { authService } from './authService'; +import { scheduleWithManager } from './scheduling/shiftScheduler'; +import { transformToSchedulingData } from './scheduling/dataAdapter'; +import { AssignmentResult, WeeklyPattern } from './scheduling/types'; const API_BASE_URL = 'http://localhost:3002/api/scheduled-shifts'; -export interface AssignmentResult { - assignments: { [shiftId: string]: string[] }; - violations: string[]; - success: boolean; - pattern: WeeklyPattern; -} - -export interface WeeklyPattern { - weekShifts: ScheduledShift[]; - assignments: { [shiftId: string]: string[] }; - weekNumber: number; -} - // Helper function to get auth headers const getAuthHeaders = () => { const token = localStorage.getItem('token'); @@ -137,41 +127,62 @@ export class ShiftAssignmentService { constraints: any = {} ): Promise { - console.log('🔄 Starting weekly pattern assignment...'); + console.log('🔄 Starting new scheduling algorithm...'); - // Get defined shifts + // Get defined shifts for the first week const definedShifts = this.getDefinedShifts(shiftPlan); - const activeEmployees = employees.filter(emp => emp.isActive); + const firstWeekShifts = this.getFirstWeekShifts(definedShifts); - console.log('📊 Plan analysis:'); - console.log('- Total shifts in plan:', definedShifts.length); - console.log('- Active employees:', activeEmployees.length); + console.log('📊 First week analysis:', { + totalShifts: definedShifts.length, + firstWeekShifts: firstWeekShifts.length, + employees: employees.length + }); - // STRATEGY: Create weekly pattern and repeat - const weeklyPattern = await this.createWeeklyPattern( - definedShifts, - activeEmployees, - availabilities, - constraints.enforceNoTraineeAlone + // Transform data for scheduling algorithm + const { schedulingEmployees, schedulingShifts, managerShifts } = transformToSchedulingData( + employees.filter(emp => emp.isActive), + firstWeekShifts, + availabilities ); - console.log('🎯 Weekly pattern created for', weeklyPattern.weekShifts.length, 'shifts'); + console.log('🎯 Transformed data for scheduling:', { + employees: schedulingEmployees.length, + shifts: schedulingShifts.length, + managerShifts: managerShifts.length + }); - // Apply pattern to all weeks in the plan - const assignments = this.applyWeeklyPattern(definedShifts, weeklyPattern); - - const violations = this.findViolations(assignments, activeEmployees, definedShifts, constraints.enforceNoTraineeAlone); + // Run the scheduling algorithm + const schedulingResult = scheduleWithManager( + schedulingShifts, + schedulingEmployees, + managerShifts, + { + enforceNoTraineeAlone: constraints.enforceNoTraineeAlone ?? true, + enforceExperiencedWithChef: constraints.enforceExperiencedWithChef ?? true, + maxRepairAttempts: constraints.maxRepairAttempts ?? 50 + } + ); - console.log('📊 Weekly pattern assignment completed:'); - console.log('- Pattern shifts:', weeklyPattern.weekShifts.length); - console.log('- Total plan shifts:', definedShifts.length); - console.log('- Assignments made:', Object.values(assignments).flat().length); - console.log('- Violations:', violations.length); + console.log('📊 Scheduling completed:', { + assignments: Object.keys(schedulingResult.assignments).length, + violations: schedulingResult.violations.length, + success: schedulingResult.success + }); + + // Apply weekly pattern to all shifts + const weeklyPattern: WeeklyPattern = { + weekShifts: firstWeekShifts, + assignments: schedulingResult.assignments, + weekNumber: 1 + }; + + const allAssignments = this.applyWeeklyPattern(definedShifts, weeklyPattern); return { - assignments, - violations, - success: violations.length === 0, + assignments: allAssignments, + violations: schedulingResult.violations, + success: schedulingResult.violations.length === 0, pattern: weeklyPattern }; }