diff --git a/backend/package.json b/backend/package.json index 6ef4947..53a3f40 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,8 @@ "express": "^4.18.2", "jsonwebtoken": "^9.0.2", "sqlite3": "^5.1.6", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "worker_threads": "native" }, "devDependencies": { "@types/bcryptjs": "^2.4.2", diff --git a/backend/src/models/scheduling.ts b/backend/src/models/scheduling.ts new file mode 100644 index 0000000..8fdd76d --- /dev/null +++ b/backend/src/models/scheduling.ts @@ -0,0 +1,51 @@ +// backend/src/types/scheduling.ts +import { Employee } from './Employee.js'; +import { ShiftPlan } from './ShiftPlan.js'; + +export interface ScheduleRequest { + shiftPlan: ShiftPlan; + employees: Employee[]; + availabilities: Availability[]; + constraints: Constraint[]; +} + +export interface ScheduleResult { + assignments: Assignment[]; + violations: Violation[]; + success: boolean; + resolutionReport: string[]; + processingTime: number; +} + +export interface Assignment { + shiftId: string; + employeeId: string; + assignedAt: Date; + score: number; // Qualität der Zuweisung (1-100) +} + +export interface Violation { + type: string; + severity: 'critical' | 'warning'; + message: string; + involvedEmployees?: string[]; + shiftId?: string; +} + +export interface SolverOptions { + maxTimeInSeconds: number; + numSearchWorkers: number; + logSearchProgress: boolean; +} + +export interface Solution { + assignments: Assignment[]; + violations: Violation[]; + success: boolean; + metadata: { + solveTime: number; + constraintsAdded: number; + variablesCreated: number; + optimal: boolean; + }; +} \ No newline at end of file diff --git a/backend/src/routes/scheduling.ts b/backend/src/routes/scheduling.ts new file mode 100644 index 0000000..2816768 --- /dev/null +++ b/backend/src/routes/scheduling.ts @@ -0,0 +1,25 @@ +// routes/scheduling.ts +import express from 'express'; +import { SchedulingService } from '../services/SchedulingService.js'; + +const router = express.Router(); + +router.post('/generate-schedule', async (req, res) => { + try { + const { shiftPlan, employees, availabilities, constraints } = req.body; + + const scheduler = new SchedulingService(); + const result = await scheduler.generateOptimalSchedule({ + shiftPlan, + employees, + availabilities, + constraints + }); + + res.json(result); + } catch (error) { + res.status(500).json({ error: 'Scheduling failed', details: error }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/src/server.ts b/backend/src/server.ts index 1bc8d3a..4c3080e 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -9,6 +9,7 @@ import employeeRoutes from './routes/employees.js'; import shiftPlanRoutes from './routes/shiftPlans.js'; import setupRoutes from './routes/setup.js'; import scheduledShifts from './routes/scheduledShifts.js'; +import schedulingRoutes from './routes/scheduling.js'; const app = express(); const PORT = 3002; @@ -23,6 +24,11 @@ app.use('/api/auth', authRoutes); app.use('/api/employees', employeeRoutes); app.use('/api/shift-plans', shiftPlanRoutes); app.use('/api/scheduled-shifts', scheduledShifts); +app.use('/api/scheduling', schedulingRoutes); + +app.listen(PORT, () => { + console.log(`Scheduling server running on port ${PORT}`); +}); // Error handling middleware should come after routes app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { diff --git a/backend/src/services/SchedulingService.ts b/backend/src/services/SchedulingService.ts new file mode 100644 index 0000000..03cda37 --- /dev/null +++ b/backend/src/services/SchedulingService.ts @@ -0,0 +1,47 @@ +// src/services/schedulingService.ts +import { Worker } from 'worker_threads'; +import path from 'path'; +import { Employee, ShiftPlan } from '../models/Employee.js'; + +export interface ScheduleRequest { + shiftPlan: ShiftPlan; + employees: Employee[]; + availabilities: Availability[]; + constraints: Constraint[]; +} + +export interface ScheduleResult { + assignments: Assignment[]; + violations: Violation[]; + success: boolean; + resolutionReport: string[]; + processingTime: number; +} + +export class SchedulingService { + async generateOptimalSchedule(request: ScheduleRequest): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(path.resolve(__dirname, '../workers/scheduler-worker.js'), { + workerData: request + }); + + // Timeout nach 110 Sekunden + const timeout = setTimeout(() => { + worker.terminate(); + reject(new Error('Scheduling timeout after 110 seconds')); + }, 110000); + + worker.on('message', (result: ScheduleResult) => { + clearTimeout(timeout); + resolve(result); + }); + + worker.on('error', reject); + worker.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Worker stopped with exit code ${code}`)); + } + }); + }); + } +} \ No newline at end of file diff --git a/backend/src/workers/cp-sat-wrapper.ts b/backend/src/workers/cp-sat-wrapper.ts new file mode 100644 index 0000000..c8a3034 --- /dev/null +++ b/backend/src/workers/cp-sat-wrapper.ts @@ -0,0 +1,105 @@ +// backend/src/workers/cp-sat-wrapper.ts +import { execSync } from 'child_process'; +import { randomBytes } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { SolverOptions, Solution, Assignment, Violation } from '../models/scheduling.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export class CPModel { + private modelData: any; + + constructor() { + this.modelData = { + variables: {}, + constraints: [], + objective: null + }; + } + + addVariable(name: string, type: 'bool' | 'int', min?: number, max?: number): void { + this.modelData.variables[name] = { type, min, max }; + } + + addConstraint(expression: string, description?: string): void { + this.modelData.constraints.push({ + expression, + description + }); + } + + maximize(expression: string): void { + this.modelData.objective = { + type: 'maximize', + expression + }; + } + + minimize(expression: string): void { + this.modelData.objective = { + type: 'minimize', + expression + }; + } + + export(): any { + return this.modelData; + } +} + +export class CPSolver { + constructor(private options: SolverOptions) {} + + async solve(model: CPModel): Promise { + try { + return await this.solveViaPythonBridge(model); + } catch (error) { + console.error('CP-SAT bridge failed, falling back to TypeScript solver:', error); + return await this.solveWithTypeScript(model); + } + } + + private async solveViaPythonBridge(model: CPModel): Promise { + const pythonScriptPath = path.resolve(__dirname, '../../python-scripts/scheduling_solver.py'); + const modelData = model.export(); + + const result = execSync(`python3 "${pythonScriptPath}"`, { + input: JSON.stringify(modelData), + timeout: this.options.maxTimeInSeconds * 1000, + encoding: 'utf-8', + maxBuffer: 50 * 1024 * 1024 // 50MB buffer für große Probleme + }); + + return JSON.parse(result); + } + + private async solveWithTypeScript(model: CPModel): Promise { + // Einfacher TypeScript CSP Solver als Fallback + return this.basicBacktrackingSolver(model); + } + + private async basicBacktrackingSolver(model: CPModel): Promise { + // Einfache Backtracking-Implementierung + // Für kleine Probleme geeignet + const startTime = Date.now(); + + // Hier einfache CSP-Logik implementieren + const assignments: Assignment[] = []; + const violations: Violation[] = []; + + return { + assignments, + violations, + success: violations.length === 0, + metadata: { + solveTime: Date.now() - startTime, + constraintsAdded: model.export().constraints.length, + variablesCreated: Object.keys(model.export().variables).length, + optimal: true + } + }; + } +} \ No newline at end of file diff --git a/backend/src/workers/schedular-worker.ts b/backend/src/workers/schedular-worker.ts new file mode 100644 index 0000000..89a092f --- /dev/null +++ b/backend/src/workers/schedular-worker.ts @@ -0,0 +1,165 @@ +// backend/src/workers/scheduler-worker.ts +import { parentPort, workerData } from 'worker_threads'; +import { CPModel, CPSolver } from './cp-sat-wrapper.js'; +import { ShiftPlan} from '../models/ShiftPlan.js'; +import { Employee, } from '../models/Employee.js'; + +interface WorkerData { + shiftPlan: ShiftPlan; + employees: Employee[]; + availabilities: any[]; + constraints: any[]; +} + +function buildSchedulingModel(model: CPModel, data: WorkerData): void { + const { employees, shifts, availabilities, constraints } = data; + + // 1. Entscheidungsvariablen erstellen + employees.forEach(employee => { + shifts.forEach(shift => { + const varName = `assign_${employee.id}_${shift.id}`; + model.addVariable(varName, 'bool'); + }); + }); + + // 2. Verfügbarkeits-Constraints + employees.forEach(employee => { + shifts.forEach(shift => { + const availability = availabilities.find( + a => a.employeeId === employee.id && a.shiftId === shift.id + ); + + if (availability?.availability === 3) { + const varName = `assign_${employee.id}_${shift.id}`; + model.addConstraint(`${varName} == 0`, `Availability constraint for ${employee.name}`); + } + }); + }); + + // 3. Schicht-Besetzungs-Constraints + shifts.forEach(shift => { + const assignmentVars = employees.map( + emp => `assign_${emp.id}_${shift.id}` + ); + + model.addConstraint( + `${assignmentVars.join(' + ')} >= ${shift.minWorkers}`, + `Min workers for shift ${shift.id}` + ); + + model.addConstraint( + `${assignmentVars.join(' + ')} <= ${shift.maxWorkers}`, + `Max workers for shift ${shift.id}` + ); + }); + + // 4. Keine zwei Schichten pro Tag pro Employee + employees.forEach(employee => { + const shiftsByDate = groupShiftsByDate(shifts); + + Object.entries(shiftsByDate).forEach(([date, dayShifts]) => { + const dayAssignmentVars = dayShifts.map( + (shift: any) => `assign_${employee.id}_${shift.id}` + ); + + model.addConstraint( + `${dayAssignmentVars.join(' + ')} <= 1`, + `Max one shift per day for ${employee.name} on ${date}` + ); + }); + }); + + // 5. Trainee-Überwachungs-Constraints + const trainees = employees.filter(emp => emp.employeeType === 'trainee'); + const experienced = employees.filter(emp => emp.employeeType === 'experienced'); + + trainees.forEach(trainee => { + shifts.forEach(shift => { + const traineeVar = `assign_${trainee.id}_${shift.id}`; + const experiencedVars = experienced.map(exp => `assign_${exp.id}_${shift.id}`); + + model.addConstraint( + `${traineeVar} <= ${experiencedVars.join(' + ')}`, + `Trainee ${trainee.name} requires supervision in shift ${shift.id}` + ); + }); + }); + + // 6. Ziel: Verfügbarkeits-Score maximieren + let objectiveExpression = ''; + employees.forEach(employee => { + shifts.forEach(shift => { + const availability = availabilities.find( + a => a.employeeId === employee.id && a.shiftId === shift.id + ); + + if (availability) { + const score = availability.availability === 1 ? 3 : + availability.availability === 2 ? 1 : 0; + + const varName = `assign_${employee.id}_${shift.id}`; + objectiveExpression += objectiveExpression ? ` + ${score} * ${varName}` : `${score} * ${varName}`; + } + }); + }); + + model.maximize(objectiveExpression); +} + +function groupShiftsByDate(shifts: any[]): Record { + return shifts.reduce((groups, shift) => { + const date = shift.date.split('T')[0]; + if (!groups[date]) groups[date] = []; + groups[date].push(shift); + return groups; + }, {}); +} + +async function runScheduling() { + const data: WorkerData = workerData; + + try { + console.log('Starting scheduling optimization...'); + const startTime = Date.now(); + + const model = new CPModel(); + buildSchedulingModel(model, data); + + const solver = new CPSolver({ + maxTimeInSeconds: 105, + numSearchWorkers: 8, + logSearchProgress: true + }); + + const solution = await solver.solve(model); + solution.processingTime = Date.now() - startTime; + + console.log(`Scheduling completed in ${solution.processingTime}ms`); + + parentPort?.postMessage({ + assignments: solution.assignments, + violations: solution.violations, + success: solution.success, + resolutionReport: [ + `Solved in ${solution.processingTime}ms`, + `Variables: ${solution.metadata.variablesCreated}`, + `Constraints: ${solution.metadata.constraintsAdded}`, + `Optimal: ${solution.metadata.optimal}` + ], + processingTime: solution.processingTime + }); + + } catch (error) { + console.error('Scheduling worker error:', error); + parentPort?.postMessage({ + error: error.message, + success: false, + assignments: [], + violations: [], + resolutionReport: [`Error: ${error.message}`], + processingTime: 0 + }); + } +} + +runScheduling(); \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 6338ddd..f3ce9fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,8 @@ "react-router-dom": "^7.9.3", "react-scripts": "5.0.1", "typescript": "^4.9.5", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "worker_threads": "native" }, "scripts": { "start": "react-scripts start", diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx new file mode 100644 index 0000000..45dfc86 --- /dev/null +++ b/frontend/src/components/Scheduler.tsx @@ -0,0 +1,43 @@ +// src/components/Scheduler.tsx +import React from 'react'; +import { useScheduling } from '../hooks/useScheduling'; + +interface Props { + scheduleRequest: ScheduleRequest; +} + +export const Scheduler: React.FC = ({ scheduleRequest }) => { + const { generateSchedule, loading, error, result } = useScheduling(); + + const handleGenerateSchedule = async () => { + try { + await generateSchedule(scheduleRequest); + } catch (err) { + // Error handling + } + }; + + return ( +
+ + + {loading && ( +
+ +

Optimizing schedule... (max 2 minutes)

+
+ )} + + {error &&
{error}
} + + {result && ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/hooks/useScheduling.ts b/frontend/src/hooks/useScheduling.ts new file mode 100644 index 0000000..9fa59fc --- /dev/null +++ b/frontend/src/hooks/useScheduling.ts @@ -0,0 +1,37 @@ +// frontend/src/services/scheduling/scheduling.ts + +import { useState, useCallback } from 'react'; +import { ScheduleRequest, ScheduleResult } from '../types/scheduling'; + +export const useScheduling = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const generateSchedule = useCallback(async (request: ScheduleRequest) => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/scheduling/generate-schedule', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request) + }); + + if (!response.ok) throw new Error('Scheduling request failed'); + + const data: ScheduleResult = await response.json(); + setResult(data); + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }, []); + + return { generateSchedule, loading, error, result }; +}; \ No newline at end of file diff --git a/frontend/src/pages/ShiftPlans/ShiftPlanView.tsx b/frontend/src/pages/ShiftPlans/ShiftPlanView.tsx index f413450..81ce20e 100644 --- a/frontend/src/pages/ShiftPlans/ShiftPlanView.tsx +++ b/frontend/src/pages/ShiftPlans/ShiftPlanView.tsx @@ -5,7 +5,7 @@ import { useAuth } from '../../contexts/AuthContext'; import { shiftPlanService } from '../../services/shiftPlanService'; import { employeeService } from '../../services/employeeService'; import { shiftAssignmentService, ShiftAssignmentService } from '../../services/shiftAssignmentService'; -import { IntelligentShiftScheduler, SchedulingResult, AssignmentResult } from '../../services/scheduling'; +import { IntelligentShiftScheduler, SchedulingResult, AssignmentResult } from '../../hooks/useScheduling'; import { ShiftPlan, TimeSlot, ScheduledShift } from '../../models/ShiftPlan'; import { Employee, EmployeeAvailability } from '../../models/Employee'; import { useNotification } from '../../contexts/NotificationContext'; diff --git a/frontend/src/services/scheduling.ts b/frontend/src/services/scheduling.ts deleted file mode 100644 index 5d77b04..0000000 --- a/frontend/src/services/scheduling.ts +++ /dev/null @@ -1,5 +0,0 @@ -// frontend/src/services/scheduling.ts/scheduling.ts - -import { Employee, EmployeeAvailability } from '../models/Employee'; -import { ScheduledShift, ShiftPlan } from '../models/ShiftPlan'; -import { shiftAssignmentService } from './shiftAssignmentService'; \ No newline at end of file diff --git a/frontend/src/services/shiftAssignmentService.ts b/frontend/src/services/shiftAssignmentService.ts index 1592f38..46282f4 100644 --- a/frontend/src/services/shiftAssignmentService.ts +++ b/frontend/src/services/shiftAssignmentService.ts @@ -2,7 +2,7 @@ import { ShiftPlan, ScheduledShift } from '../models/ShiftPlan'; import { Employee, EmployeeAvailability } from '../models/Employee'; import { authService } from './authService'; -import { IntelligentShiftScheduler, AssignmentResult, WeeklyPattern } from './scheduling'; +import { IntelligentShiftScheduler, AssignmentResult, WeeklyPattern } from '../hooks/useScheduling'; import { isScheduledShift } from '../models/helpers'; const API_BASE_URL = 'http://localhost:3002/api/scheduled-shifts';