seperated files for scheduling logic

This commit is contained in:
2025-10-14 10:07:57 +02:00
parent 310b7806e5
commit 42ec705298
8 changed files with 530 additions and 39 deletions

View File

@@ -4,7 +4,8 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { shiftPlanService } from '../../services/shiftPlanService'; import { shiftPlanService } from '../../services/shiftPlanService';
import { employeeService } from '../../services/employeeService'; 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 { ShiftPlan, TimeSlot } from '../../models/ShiftPlan';
import { Employee, EmployeeAvailability } from '../../models/Employee'; import { Employee, EmployeeAvailability } from '../../models/Employee';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';

View File

@@ -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<string, Map<string, number>>();
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();
}

View File

@@ -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';

View File

@@ -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<string, SchedulingEmployee>,
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<string, SchedulingEmployee>,
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<string, SchedulingEmployee>,
allShifts: SchedulingShift[]
): boolean {
// Try multiple repair strategies
return attemptSwapBringErfahrener(targetShiftId, assignments, employees, allShifts) ||
attemptRepairMoveErfahrener(targetShiftId, assignments, employees, allShifts);
}

View File

@@ -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
};
}

View File

@@ -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<string, number>; // 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;
}

View File

@@ -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<string, SchedulingEmployee>): 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<string, SchedulingEmployee>): 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<string, SchedulingEmployee>
): 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;
}

View File

@@ -2,22 +2,12 @@
import { ShiftPlan, ScheduledShift } from '../models/ShiftPlan'; import { ShiftPlan, ScheduledShift } from '../models/ShiftPlan';
import { Employee, EmployeeAvailability } from '../models/Employee'; import { Employee, EmployeeAvailability } from '../models/Employee';
import { authService } from './authService'; 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'; 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 // Helper function to get auth headers
const getAuthHeaders = () => { const getAuthHeaders = () => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -137,41 +127,62 @@ export class ShiftAssignmentService {
constraints: any = {} constraints: any = {}
): Promise<AssignmentResult> { ): Promise<AssignmentResult> {
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 definedShifts = this.getDefinedShifts(shiftPlan);
const activeEmployees = employees.filter(emp => emp.isActive); const firstWeekShifts = this.getFirstWeekShifts(definedShifts);
console.log('📊 Plan analysis:'); console.log('📊 First week analysis:', {
console.log('- Total shifts in plan:', definedShifts.length); totalShifts: definedShifts.length,
console.log('- Active employees:', activeEmployees.length); firstWeekShifts: firstWeekShifts.length,
employees: employees.length
});
// STRATEGY: Create weekly pattern and repeat // Transform data for scheduling algorithm
const weeklyPattern = await this.createWeeklyPattern( const { schedulingEmployees, schedulingShifts, managerShifts } = transformToSchedulingData(
definedShifts, employees.filter(emp => emp.isActive),
activeEmployees, firstWeekShifts,
availabilities, availabilities
constraints.enforceNoTraineeAlone
); );
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 // Run the scheduling algorithm
const assignments = this.applyWeeklyPattern(definedShifts, weeklyPattern); const schedulingResult = scheduleWithManager(
schedulingShifts,
const violations = this.findViolations(assignments, activeEmployees, definedShifts, constraints.enforceNoTraineeAlone); schedulingEmployees,
managerShifts,
{
enforceNoTraineeAlone: constraints.enforceNoTraineeAlone ?? true,
enforceExperiencedWithChef: constraints.enforceExperiencedWithChef ?? true,
maxRepairAttempts: constraints.maxRepairAttempts ?? 50
}
);
console.log('📊 Weekly pattern assignment completed:'); console.log('📊 Scheduling completed:', {
console.log('- Pattern shifts:', weeklyPattern.weekShifts.length); assignments: Object.keys(schedulingResult.assignments).length,
console.log('- Total plan shifts:', definedShifts.length); violations: schedulingResult.violations.length,
console.log('- Assignments made:', Object.values(assignments).flat().length); success: schedulingResult.success
console.log('- Violations:', violations.length); });
// Apply weekly pattern to all shifts
const weeklyPattern: WeeklyPattern = {
weekShifts: firstWeekShifts,
assignments: schedulingResult.assignments,
weekNumber: 1
};
const allAssignments = this.applyWeeklyPattern(definedShifts, weeklyPattern);
return { return {
assignments, assignments: allAssignments,
violations, violations: schedulingResult.violations,
success: violations.length === 0, success: schedulingResult.violations.length === 0,
pattern: weeklyPattern pattern: weeklyPattern
}; };
} }