mirror of
https://github.com/donpat1to/Schichtenplaner.git
synced 2025-12-01 06:55:45 +01:00
cp running
This commit is contained in:
61
backend/dockerfilexpython
Normal file
61
backend/dockerfilexpython
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Multi-stage Dockerfile for Node.js + Python application
|
||||||
|
FROM node:20-alpine AS node-base
|
||||||
|
|
||||||
|
# Install Python and build dependencies in Node stage
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
python3 \
|
||||||
|
py3-pip \
|
||||||
|
build-base \
|
||||||
|
python3-dev
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY python-scripts/requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install Node.js dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
FROM node-base AS builder
|
||||||
|
|
||||||
|
# Install all dependencies (including dev dependencies)
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node-base AS production
|
||||||
|
|
||||||
|
# Copy built application from builder stage
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/package*.json ./
|
||||||
|
|
||||||
|
# Copy Python scripts
|
||||||
|
COPY --from=builder /app/python-scripts ./python-scripts
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
# Change to non-root user
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD node dist/health-check.js
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -75,6 +75,7 @@ export interface Solution {
|
|||||||
variablesCreated: number;
|
variablesCreated: number;
|
||||||
optimal: boolean;
|
optimal: boolean;
|
||||||
};
|
};
|
||||||
|
variables?: { [key: string]: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional helper types for the scheduling system
|
// Additional helper types for the scheduling system
|
||||||
|
|||||||
@@ -2,422 +2,297 @@
|
|||||||
from ortools.sat.python import cp_model
|
from ortools.sat.python import cp_model
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from typing import List, Dict, Any, Tuple
|
import re
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import math
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
class ScheduleOptimizer:
|
class UniversalSchedulingSolver:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.model = cp_model.CpModel()
|
self.model = cp_model.CpModel()
|
||||||
self.solver = cp_model.CpSolver()
|
self.solver = cp_model.CpSolver()
|
||||||
self.assignments = {}
|
self.solver.parameters.max_time_in_seconds = 30
|
||||||
self.violations = []
|
|
||||||
self.resolution_report = []
|
|
||||||
|
|
||||||
# Solver parameters for better performance
|
|
||||||
self.solver.parameters.max_time_in_seconds = 100 # 100 seconds timeout
|
|
||||||
self.solver.parameters.num_search_workers = 8
|
self.solver.parameters.num_search_workers = 8
|
||||||
self.solver.parameters.log_search_progress = True
|
self.solver.parameters.log_search_progress = False
|
||||||
|
|
||||||
def generateOptimalSchedule(self, shiftPlan, employees, availabilities, constraints):
|
def solve_from_model_data(self, model_data):
|
||||||
"""
|
"""Solve from pre-built model data (variables, constraints, objective)"""
|
||||||
Hauptalgorithmus für die Schichtplanoptimierung
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
self.resolution_report.append("🚀 Starting scheduling optimization...")
|
variables = model_data.get('variables', {})
|
||||||
|
constraints = model_data.get('constraints', [])
|
||||||
|
objective = model_data.get('objective', None)
|
||||||
|
|
||||||
# Prepare data
|
# Create CP-SAT variables
|
||||||
scheduled_shifts = self.prepareShifts(shiftPlan)
|
cp_vars = {}
|
||||||
first_week_shifts = self.getFirstWeekShifts(scheduled_shifts)
|
for var_name, var_info in variables.items():
|
||||||
|
if var_info['type'] == 'bool':
|
||||||
|
cp_vars[var_name] = self.model.NewBoolVar(var_name)
|
||||||
|
elif var_info['type'] == 'int':
|
||||||
|
min_val = var_info.get('min', 0)
|
||||||
|
max_val = var_info.get('max', 100)
|
||||||
|
cp_vars[var_name] = self.model.NewIntVar(min_val, max_val, var_name)
|
||||||
|
|
||||||
# Separate employee types
|
# Add constraints
|
||||||
managers = self.filterEmployees(employees, 'manager')
|
constraints_added = 0
|
||||||
workers = self.filterEmployees(employees, lambda e: e.get('employeeType') != 'manager')
|
for constraint in constraints:
|
||||||
experienced = self.filterEmployees(employees, lambda e: e.get('role') != 'admin' and e.get('employeeType') == 'experienced')
|
if self._add_constraint(constraint['expression'], cp_vars):
|
||||||
trainees = self.filterEmployees(employees, lambda e: e.get('role') != 'admin' and e.get('employeeType') == 'trainee')
|
constraints_added += 1
|
||||||
|
|
||||||
self.resolution_report.append(f"📊 Employee counts: {len(managers)} managers, {len(experienced)} experienced, {len(trainees)} trainees")
|
# Set objective
|
||||||
|
if objective:
|
||||||
# Create experienced x trainees hashmap
|
try:
|
||||||
hashmap_experienced_trainees = self.createTraineePartners(workers, availabilities)
|
if objective['type'] == 'maximize':
|
||||||
|
self.model.Maximize(self._parse_expression(objective['expression'], cp_vars))
|
||||||
# Optimize distribution
|
|
||||||
optimized_assignments = self.optimizeDistribution(
|
|
||||||
trainees, experienced, managers, availabilities, constraints, first_week_shifts
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
final_violations = self.detectAllViolations(
|
|
||||||
optimized_assignments, employees, availabilities, constraints, first_week_shifts
|
|
||||||
)
|
|
||||||
self.violations.extend(final_violations)
|
|
||||||
|
|
||||||
# Fix violations
|
|
||||||
fixed_assignments = self.fixViolations(
|
|
||||||
optimized_assignments, employees, availabilities, constraints, first_week_shifts, maxIterations=20
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add managers to priority shifts
|
|
||||||
final_assignments = self.assignManagersToPriority(
|
|
||||||
managers, fixed_assignments, availabilities, first_week_shifts
|
|
||||||
)
|
|
||||||
|
|
||||||
# Final validation
|
|
||||||
final_violations = self.detectAllViolations(
|
|
||||||
final_assignments, employees, availabilities, constraints, first_week_shifts
|
|
||||||
)
|
|
||||||
|
|
||||||
success = (self.countCriticalViolations(final_violations) == 0)
|
|
||||||
|
|
||||||
self.resolution_report.append(f"✅ Scheduling completed: {success}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'assignments': self.formatAssignments(final_assignments),
|
|
||||||
'violations': final_violations,
|
|
||||||
'success': success,
|
|
||||||
'resolution_report': self.resolution_report
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as error:
|
|
||||||
self.resolution_report.append(f"❌ Error: {str(error)}")
|
|
||||||
return self.errorResult(error)
|
|
||||||
|
|
||||||
def optimizeDistribution(self, trainees, experienced, managers, availabilities, constraints, shifts):
|
|
||||||
"""
|
|
||||||
Optimiert die Verteilung der Schichten unter Berücksichtigung aller Constraints
|
|
||||||
"""
|
|
||||||
# Reset model for new optimization
|
|
||||||
self.model = cp_model.CpModel()
|
|
||||||
|
|
||||||
assignments = {}
|
|
||||||
all_employees = experienced + trainees
|
|
||||||
|
|
||||||
self.resolution_report.append(f"🔧 Building model with {len(shifts)} shifts and {len(all_employees)} employees")
|
|
||||||
|
|
||||||
# Create assignment variables
|
|
||||||
for employee in all_employees:
|
|
||||||
employee_id = employee['id']
|
|
||||||
assignments[employee_id] = {}
|
|
||||||
for shift in shifts:
|
|
||||||
shift_id = shift['id']
|
|
||||||
availability = self.getAvailability(employee_id, shift_id, availabilities)
|
|
||||||
if availability in [1, 2]: # Only create variables for available shifts
|
|
||||||
var_name = f"assign_{employee_id}_{shift_id}"
|
|
||||||
assignments[employee_id][shift_id] = self.model.NewBoolVar(var_name)
|
|
||||||
|
|
||||||
# Constraint: Max 1 shift per day per employee
|
|
||||||
shifts_by_day = self.groupShiftsByDay(shifts)
|
|
||||||
for employee in all_employees:
|
|
||||||
employee_id = employee['id']
|
|
||||||
for day, day_shifts in shifts_by_day.items():
|
|
||||||
shift_vars = []
|
|
||||||
for shift in day_shifts:
|
|
||||||
if shift['id'] in assignments.get(employee_id, {}):
|
|
||||||
shift_vars.append(assignments[employee_id][shift['id']])
|
|
||||||
if shift_vars:
|
|
||||||
self.model.Add(sum(shift_vars) <= 1)
|
|
||||||
|
|
||||||
# Constraint: Each shift has required employees
|
|
||||||
for shift in shifts:
|
|
||||||
shift_id = shift['id']
|
|
||||||
shift_vars = []
|
|
||||||
for employee in all_employees:
|
|
||||||
employee_id = employee['id']
|
|
||||||
if shift_id in assignments.get(employee_id, {}):
|
|
||||||
shift_vars.append(assignments[employee_id][shift_id])
|
|
||||||
|
|
||||||
if shift_vars:
|
|
||||||
min_workers = shift.get('minWorkers', 1)
|
|
||||||
max_workers = shift.get('maxWorkers', 3)
|
|
||||||
self.model.Add(sum(shift_vars) >= min_workers)
|
|
||||||
self.model.Add(sum(shift_vars) <= max_workers)
|
|
||||||
|
|
||||||
# Constraint: Trainees cannot work alone
|
|
||||||
for shift in shifts:
|
|
||||||
shift_id = shift['id']
|
|
||||||
trainee_vars = []
|
|
||||||
experienced_vars = []
|
|
||||||
|
|
||||||
for trainee in trainees:
|
|
||||||
trainee_id = trainee['id']
|
|
||||||
if shift_id in assignments.get(trainee_id, {}):
|
|
||||||
trainee_vars.append(assignments[trainee_id][shift_id])
|
|
||||||
|
|
||||||
for exp in experienced:
|
|
||||||
exp_id = exp['id']
|
|
||||||
if shift_id in assignments.get(exp_id, {}):
|
|
||||||
experienced_vars.append(assignments[exp_id][shift_id])
|
|
||||||
|
|
||||||
if trainee_vars and experienced_vars:
|
|
||||||
# If any trainee is assigned, at least one experienced must be assigned
|
|
||||||
for trainee_var in trainee_vars:
|
|
||||||
self.model.Add(sum(experienced_vars) >= 1).OnlyEnforceIf(trainee_var)
|
|
||||||
|
|
||||||
# Prevent shifts with only one worker unless that worker can work alone
|
|
||||||
for shift in shifts:
|
|
||||||
shift_id = shift['id']
|
|
||||||
|
|
||||||
# Create a variable for "this shift has exactly one worker"
|
|
||||||
shift_has_one_worker = self.model.NewBoolVar(f'shift_{shift_id}_one_worker')
|
|
||||||
shift_assignment_count = sum(assignments[emp['id']].get(shift_id, 0)
|
|
||||||
for emp in all_employees
|
|
||||||
if shift_id in assignments.get(emp['id'], {}))
|
|
||||||
|
|
||||||
# Link the count to the boolean variable
|
|
||||||
self.model.Add(shift_assignment_count == 1).OnlyEnforceIf(shift_has_one_worker)
|
|
||||||
self.model.Add(shift_assignment_count != 1).OnlyEnforceIf(shift_has_one_worker.Not())
|
|
||||||
|
|
||||||
# Create a variable for "this shift has someone who cannot work alone"
|
|
||||||
has_cannot_work_alone = self.model.NewBoolVar(f'shift_{shift_id}_cannot_work_alone')
|
|
||||||
cannot_work_alone_vars = []
|
|
||||||
for employee in all_employees:
|
|
||||||
employee_id = employee['id']
|
|
||||||
if shift_id in assignments.get(employee_id, {}):
|
|
||||||
is_experienced = employee.get('employeeType') == 'experienced'
|
|
||||||
can_work_alone = employee.get('canWorkAlone', False)
|
|
||||||
if not (is_experienced and can_work_alone):
|
|
||||||
cannot_work_alone_vars.append(assignments[employee_id][shift_id])
|
|
||||||
|
|
||||||
if cannot_work_alone_vars:
|
|
||||||
self.model.Add(sum(cannot_work_alone_vars) >= 1).OnlyEnforceIf(has_cannot_work_alone)
|
|
||||||
self.model.Add(sum(cannot_work_alone_vars) == 0).OnlyEnforceIf(has_cannot_work_alone.Not())
|
|
||||||
|
|
||||||
# Constraint: If shift has one worker, it cannot have someone who cannot work alone
|
|
||||||
self.model.AddImplication(shift_has_one_worker, has_cannot_work_alone.Not())
|
|
||||||
|
|
||||||
# Exact shifts per contract type
|
|
||||||
for employee in all_employees:
|
|
||||||
employee_id = employee['id']
|
|
||||||
contract_type = employee.get('contractType', 'large')
|
|
||||||
exact_shifts = 5 if contract_type == 'small' else 10
|
|
||||||
|
|
||||||
shift_vars = []
|
|
||||||
for shift in shifts:
|
|
||||||
shift_id = shift['id']
|
|
||||||
if shift_id in assignments.get(employee_id, {}):
|
|
||||||
shift_vars.append(assignments[employee_id][shift_id])
|
|
||||||
|
|
||||||
if shift_vars:
|
|
||||||
self.model.Add(sum(shift_vars) == exact_shifts)
|
|
||||||
self.resolution_report.append(f"📋 Employee {employee_id}: {exact_shifts} shifts ({contract_type} contract)")
|
|
||||||
|
|
||||||
# Constraint: Contract hours limits
|
|
||||||
for employee in all_employees:
|
|
||||||
employee_id = employee['id']
|
|
||||||
contract_type = employee.get('contractType', 'large')
|
|
||||||
max_hours = 40 if contract_type == 'large' else 20
|
|
||||||
|
|
||||||
total_hours_var = 0
|
|
||||||
for shift in shifts:
|
|
||||||
shift_id = shift['id']
|
|
||||||
if shift_id in assignments.get(employee_id, {}):
|
|
||||||
# Assume 8 hours per shift (adjust based on your time slots)
|
|
||||||
shift_hours = 8
|
|
||||||
total_hours_var += assignments[employee_id][shift_id] * shift_hours
|
|
||||||
|
|
||||||
self.model.Add(total_hours_var <= max_hours)
|
|
||||||
|
|
||||||
# Objective: Maximize preferred assignments
|
|
||||||
objective_terms = []
|
|
||||||
for employee in all_employees:
|
|
||||||
employee_id = employee['id']
|
|
||||||
for shift in shifts:
|
|
||||||
shift_id = shift['id']
|
|
||||||
if shift_id in assignments.get(employee_id, {}):
|
|
||||||
availability = self.getAvailability(employee_id, shift_id, availabilities)
|
|
||||||
if availability == 1: # Preferred
|
|
||||||
objective_terms.append(assignments[employee_id][shift_id] * 10)
|
|
||||||
elif availability == 2: # Available
|
|
||||||
objective_terms.append(assignments[employee_id][shift_id] * 5)
|
|
||||||
# Penalize unavailable assignments (shouldn't happen due to constraints)
|
|
||||||
else:
|
else:
|
||||||
objective_terms.append(assignments[employee_id][shift_id] * -1000)
|
self.model.Minimize(self._parse_expression(objective['expression'], cp_vars))
|
||||||
|
except Exception as e:
|
||||||
self.model.Maximize(sum(objective_terms))
|
print(f"Objective parsing failed: {e}", file=sys.stderr)
|
||||||
|
# Add a default objective if main objective fails
|
||||||
# Solve the model
|
self.model.Maximize(sum(cp_vars.values()))
|
||||||
self.resolution_report.append("🎯 Solving optimization model...")
|
|
||||||
status = self.solver.Solve(self.model)
|
|
||||||
|
|
||||||
if status == cp_model.OPTIMAL:
|
|
||||||
self.resolution_report.append("✅ Optimal solution found!")
|
|
||||||
elif status == cp_model.FEASIBLE:
|
|
||||||
self.resolution_report.append("⚠️ Feasible solution found (may not be optimal)")
|
|
||||||
else:
|
|
||||||
self.resolution_report.append("❌ No solution found")
|
|
||||||
|
|
||||||
if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE:
|
|
||||||
return self.extractAssignments(assignments, all_employees, shifts)
|
|
||||||
else:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def groupShiftsByDay(self, shifts):
|
|
||||||
"""Group shifts by date"""
|
|
||||||
shifts_by_day = defaultdict(list)
|
|
||||||
for shift in shifts:
|
|
||||||
date = shift.get('date', 'unknown')
|
|
||||||
shifts_by_day[date].append(shift)
|
|
||||||
return shifts_by_day
|
|
||||||
|
|
||||||
def getAvailability(self, employee_id, shift_id, availabilities):
|
|
||||||
"""Get availability level for employee and shift"""
|
|
||||||
for avail in availabilities:
|
|
||||||
if avail.get('employeeId') == employee_id and avail.get('shiftId') == shift_id:
|
|
||||||
return avail.get('availability', 2) # Default to available
|
|
||||||
return 2 # Default to available if no preference specified
|
|
||||||
|
|
||||||
def extractAssignments(self, assignments, employees, shifts):
|
|
||||||
"""Extract assignments from solution"""
|
|
||||||
result_assignments = {}
|
|
||||||
|
|
||||||
# Initialize with empty lists
|
|
||||||
for shift in shifts:
|
|
||||||
result_assignments[shift['id']] = []
|
|
||||||
|
|
||||||
# Fill with assigned employees
|
|
||||||
for employee in employees:
|
|
||||||
employee_id = employee['id']
|
|
||||||
for shift in shifts:
|
|
||||||
shift_id = shift['id']
|
|
||||||
if (shift_id in assignments.get(employee_id, {}) and
|
|
||||||
self.solver.Value(assignments[employee_id][shift_id]) == 1):
|
|
||||||
result_assignments[shift_id].append(employee_id)
|
|
||||||
|
|
||||||
return result_assignments
|
|
||||||
|
|
||||||
def formatAssignments(self, assignments):
|
|
||||||
"""Format assignments for frontend consumption"""
|
|
||||||
formatted = {}
|
|
||||||
for shift_id, employee_ids in assignments.items():
|
|
||||||
formatted[shift_id] = employee_ids
|
|
||||||
return formatted
|
|
||||||
|
|
||||||
def prepareShifts(self, shiftPlan):
|
|
||||||
"""Prepare shifts for optimization"""
|
|
||||||
if 'shifts' in shiftPlan:
|
|
||||||
return shiftPlan['shifts']
|
|
||||||
return []
|
|
||||||
|
|
||||||
def filterEmployees(self, employees, condition):
|
|
||||||
"""Filter employees based on condition"""
|
|
||||||
if callable(condition):
|
|
||||||
return [emp for emp in employees if condition(emp)]
|
|
||||||
elif isinstance(condition, str):
|
|
||||||
return [emp for emp in employees if emp.get('employeeType') == condition]
|
|
||||||
return []
|
|
||||||
|
|
||||||
def getFirstWeekShifts(self, shifts):
|
|
||||||
"""Get shifts for the first week (simplified)"""
|
|
||||||
# For simplicity, return all shifts or implement week filtering logic
|
|
||||||
return shifts
|
|
||||||
|
|
||||||
def createTraineePartners(self, workers, availabilities):
|
|
||||||
"""Create trainee-experienced partnerships based on availability"""
|
|
||||||
# Simplified implementation - return empty dict for now
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def detectAllViolations(self, assignments, employees, availabilities, constraints, shifts):
|
|
||||||
"""Detect all constraint violations"""
|
|
||||||
violations = []
|
|
||||||
employee_map = {emp['id']: emp for emp in employees}
|
|
||||||
|
|
||||||
# Check for understaffed shifts
|
|
||||||
for shift in shifts:
|
|
||||||
shift_id = shift['id']
|
|
||||||
assigned_count = len(assignments.get(shift_id, []))
|
|
||||||
min_required = shift.get('minWorkers', 1)
|
|
||||||
|
|
||||||
if assigned_count < min_required:
|
# Solve
|
||||||
violations.append(f"UNDERSTAFFED: Shift {shift_id} has {assigned_count} employees but requires {min_required}")
|
status = self.solver.Solve(self.model)
|
||||||
|
|
||||||
# Check for trainee supervision
|
|
||||||
for shift in shifts:
|
|
||||||
shift_id = shift['id']
|
|
||||||
assigned_employees = assignments.get(shift_id, [])
|
|
||||||
has_trainee = any(employee_map.get(emp_id, {}).get('employeeType') == 'trainee' for emp_id in assigned_employees)
|
|
||||||
has_experienced = any(employee_map.get(emp_id, {}).get('employeeType') == 'experienced' for emp_id in assigned_employees)
|
|
||||||
|
|
||||||
if has_trainee and not has_experienced:
|
result = self._format_solution(status, cp_vars, model_data)
|
||||||
violations.append(f"TRAINEE_UNSUPERVISED: Shift {shift_id} has trainee but no experienced employee")
|
result['metadata']['constraintsAdded'] = constraints_added
|
||||||
|
return result
|
||||||
# Check for multiple shifts per day
|
|
||||||
shifts_by_day = self.groupShiftsByDay(shifts)
|
except Exception as e:
|
||||||
for employee in employees:
|
return self._error_result(str(e))
|
||||||
employee_id = employee['id']
|
|
||||||
for date, day_shifts in shifts_by_day.items():
|
def _add_constraint(self, expression, cp_vars):
|
||||||
shifts_assigned = 0
|
"""Add constraint from expression string with enhanced parsing"""
|
||||||
for shift in day_shifts:
|
try:
|
||||||
if employee_id in assignments.get(shift['id'], []):
|
expression = expression.strip()
|
||||||
shifts_assigned += 1
|
|
||||||
|
# Handle implication constraints (=>)
|
||||||
|
if '=>' in expression:
|
||||||
|
left, right = expression.split('=>', 1)
|
||||||
|
left_expr = self._parse_expression(left.strip(), cp_vars)
|
||||||
|
right_expr = self._parse_expression(right.strip(), cp_vars)
|
||||||
|
|
||||||
if shifts_assigned > 1:
|
# A => B is equivalent to (not A) or B
|
||||||
violations.append(f"MULTIPLE_SHIFTS: {employee.get('name', employee_id)} has {shifts_assigned} shifts on {date}")
|
# In CP-SAT: AddBoolOr([A.Not(), B])
|
||||||
|
if hasattr(left_expr, 'Not') and hasattr(right_expr, 'Index'):
|
||||||
# Check contract type constraints
|
self.model.AddImplication(left_expr, right_expr)
|
||||||
for employee in employees:
|
else:
|
||||||
employee_id = employee['id']
|
# Fallback: treat as linear constraint
|
||||||
contract_type = employee.get('contractType', 'large')
|
self.model.Add(left_expr <= right_expr)
|
||||||
expected_shifts = 5 if contract_type == 'small' else 10
|
return True
|
||||||
|
|
||||||
total_shifts = 0
|
# Handle equality
|
||||||
for shift_assignments in assignments.values():
|
if ' == ' in expression:
|
||||||
if employee_id in shift_assignments:
|
left, right = expression.split(' == ', 1)
|
||||||
total_shifts += 1
|
left_expr = self._parse_expression(left.strip(), cp_vars)
|
||||||
|
right_expr = self._parse_expression(right.strip(), cp_vars)
|
||||||
|
self.model.Add(left_expr == right_expr)
|
||||||
|
return True
|
||||||
|
|
||||||
if total_shifts != expected_shifts:
|
# Handle inequalities
|
||||||
violations.append(f"CONTRACT_VIOLATION: {employee.get('name', employee_id)} has {total_shifts} shifts but should have exactly {expected_shifts} ({contract_type} contract)")
|
elif ' <= ' in expression:
|
||||||
|
left, right = expression.split(' <= ', 1)
|
||||||
|
left_expr = self._parse_expression(left.strip(), cp_vars)
|
||||||
|
right_expr = self._parse_expression(right.strip(), cp_vars)
|
||||||
|
self.model.Add(left_expr <= right_expr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif ' >= ' in expression:
|
||||||
|
left, right = expression.split(' >= ', 1)
|
||||||
|
left_expr = self._parse_expression(left.strip(), cp_vars)
|
||||||
|
right_expr = self._parse_expression(right.strip(), cp_vars)
|
||||||
|
self.model.Add(left_expr >= right_expr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif ' < ' in expression:
|
||||||
|
left, right = expression.split(' < ', 1)
|
||||||
|
left_expr = self._parse_expression(left.strip(), cp_vars)
|
||||||
|
right_expr = self._parse_expression(right.strip(), cp_vars)
|
||||||
|
self.model.Add(left_expr < right_expr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif ' > ' in expression:
|
||||||
|
left, right = expression.split(' > ', 1)
|
||||||
|
left_expr = self._parse_expression(left.strip(), cp_vars)
|
||||||
|
right_expr = self._parse_expression(right.strip(), cp_vars)
|
||||||
|
self.model.Add(left_expr > right_expr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Single expression - assume it should be true
|
||||||
|
expr = self._parse_expression(expression, cp_vars)
|
||||||
|
self.model.Add(expr == 1)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Constraint skipped: {expression} - Error: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _parse_expression(self, expr, cp_vars):
|
||||||
|
"""Enhanced expression parser with better error handling"""
|
||||||
|
expr = expr.strip()
|
||||||
|
|
||||||
return violations
|
# Handle parentheses
|
||||||
|
if expr.startswith('(') and expr.endswith(')'):
|
||||||
|
return self._parse_expression(expr[1:-1], cp_vars)
|
||||||
|
|
||||||
|
# Single variable
|
||||||
|
if expr in cp_vars:
|
||||||
|
return cp_vars[expr]
|
||||||
|
|
||||||
|
# Integer constant
|
||||||
|
if expr.isdigit() or (expr.startswith('-') and expr[1:].isdigit()):
|
||||||
|
return int(expr)
|
||||||
|
|
||||||
|
# Sum of expressions
|
||||||
|
if ' + ' in expr:
|
||||||
|
parts = [self._parse_expression(p.strip(), cp_vars) for p in expr.split(' + ')]
|
||||||
|
return sum(parts)
|
||||||
|
|
||||||
|
# Multiplication with coefficient
|
||||||
|
multiplication_match = re.match(r'^(-?\d+)\s*\*\s*(\w+)$', expr)
|
||||||
|
if multiplication_match:
|
||||||
|
coef = int(multiplication_match.group(1))
|
||||||
|
var_name = multiplication_match.group(2)
|
||||||
|
if var_name in cp_vars:
|
||||||
|
return coef * cp_vars[var_name]
|
||||||
|
|
||||||
|
# Simple multiplication with *
|
||||||
|
if ' * ' in expr:
|
||||||
|
parts = expr.split(' * ')
|
||||||
|
if len(parts) == 2:
|
||||||
|
left = self._parse_expression(parts[0].strip(), cp_vars)
|
||||||
|
right = self._parse_expression(parts[1].strip(), cp_vars)
|
||||||
|
# For CP-SAT, we can only multiply by constants
|
||||||
|
if isinstance(left, int):
|
||||||
|
return left * right
|
||||||
|
elif isinstance(right, int):
|
||||||
|
return left * right
|
||||||
|
|
||||||
|
# Default: try to evaluate as integer, otherwise return 0
|
||||||
|
try:
|
||||||
|
return int(expr)
|
||||||
|
except:
|
||||||
|
# If it's a simple variable name without spaces, create a constant 0
|
||||||
|
if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', expr):
|
||||||
|
print(f"Warning: Variable {expr} not found, using 0", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _format_solution(self, status, cp_vars, model_data):
|
||||||
|
"""Format the solution for TypeScript with enhanced debugging"""
|
||||||
|
assignments = []
|
||||||
|
variables = {}
|
||||||
|
|
||||||
|
print(f"Debug: Solver status = {status}", file=sys.stderr)
|
||||||
|
print(f"Debug: Number of CP variables = {len(cp_vars)}", file=sys.stderr)
|
||||||
|
|
||||||
|
if status in [cp_model.OPTIMAL, cp_model.FEASIBLE]:
|
||||||
|
# Extract ALL variable values for debugging
|
||||||
|
for var_name, cp_var in cp_vars.items():
|
||||||
|
value = self.solver.Value(cp_var)
|
||||||
|
variables[var_name] = value
|
||||||
|
|
||||||
|
# Create assignments for true boolean variables
|
||||||
|
if value == 1 and var_name.startswith('assign_'):
|
||||||
|
parts = var_name.split('_')
|
||||||
|
if len(parts) >= 3:
|
||||||
|
employee_id = parts[1]
|
||||||
|
shift_id = '_'.join(parts[2:])
|
||||||
|
assignments.append({
|
||||||
|
'shiftId': shift_id,
|
||||||
|
'employeeId': employee_id,
|
||||||
|
'assignedAt': '2024-01-01T00:00:00Z',
|
||||||
|
'score': 100
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"Debug: Found {len(assignments)} assignments", file=sys.stderr)
|
||||||
|
print(f"Debug: First 5 assignments: {assignments[:5]}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f"Debug: Solver failed with status {status}", file=sys.stderr)
|
||||||
|
|
||||||
def fixViolations(self, assignments, employees, availabilities, constraints, shifts, maxIterations=20):
|
success = (status == cp_model.OPTIMAL or status == cp_model.FEASIBLE)
|
||||||
"""Fix violations in assignments"""
|
|
||||||
# Simplified implementation - return original assignments
|
return {
|
||||||
# In a real implementation, this would iteratively fix violations
|
'assignments': assignments,
|
||||||
return assignments
|
'violations': [],
|
||||||
|
'success': success,
|
||||||
def assignManagersToPriority(self, managers, assignments, availabilities, shifts):
|
'variables': variables, # Include ALL variables for debugging
|
||||||
"""Assign managers to priority shifts"""
|
'metadata': {
|
||||||
# Simplified implementation - return original assignments
|
'solveTime': self.solver.WallTime(),
|
||||||
return assignments
|
'constraintsAdded': len(model_data.get('constraints', [])),
|
||||||
|
'variablesCreated': len(cp_vars),
|
||||||
def countCriticalViolations(self, violations):
|
'optimal': (status == cp_model.OPTIMAL)
|
||||||
"""Count critical violations"""
|
}
|
||||||
critical_keywords = ['UNDERSTAFFED', 'TRAINEE_UNSUPERVISED', 'CONTRACT_VIOLATION']
|
}
|
||||||
return sum(1 for violation in violations if any(keyword in violation for keyword in critical_keywords))
|
|
||||||
|
def _status_string(self, status):
|
||||||
def errorResult(self, error):
|
"""Convert status code to string"""
|
||||||
|
status_map = {
|
||||||
|
cp_model.OPTIMAL: 'OPTIMAL',
|
||||||
|
cp_model.FEASIBLE: 'FEASIBLE',
|
||||||
|
cp_MODEL.INFEASIBLE: 'INFEASIBLE',
|
||||||
|
cp_MODEL.MODEL_INVALID: 'MODEL_INVALID',
|
||||||
|
cp_MODEL.UNKNOWN: 'UNKNOWN'
|
||||||
|
}
|
||||||
|
return status_map.get(status, f'UNKNOWN_STATUS_{status}')
|
||||||
|
|
||||||
|
def _error_result(self, error_msg):
|
||||||
"""Return error result"""
|
"""Return error result"""
|
||||||
return {
|
return {
|
||||||
'assignments': {},
|
'assignments': [],
|
||||||
'violations': [f'Error: {str(error)}'],
|
'violations': [f'Error: {error_msg}'],
|
||||||
'success': False,
|
'success': False,
|
||||||
'resolution_report': [f'Critical error: {str(error)}'],
|
'metadata': {
|
||||||
'error': str(error)
|
'solveTime': 0,
|
||||||
|
'constraintsAdded': 0,
|
||||||
|
'variablesCreated': 0,
|
||||||
|
'optimal': False
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Main execution for Python script
|
|
||||||
|
|
||||||
|
# Main execution
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
# Read input from stdin
|
# Read input from stdin
|
||||||
input_data = json.loads(sys.stdin.read())
|
input_data = sys.stdin.read().strip()
|
||||||
|
if not input_data:
|
||||||
|
raise ValueError("No input data provided")
|
||||||
|
|
||||||
optimizer = ScheduleOptimizer()
|
data = json.loads(input_data)
|
||||||
result = optimizer.generateOptimalSchedule(
|
|
||||||
input_data.get('shiftPlan', {}),
|
|
||||||
input_data.get('employees', []),
|
|
||||||
input_data.get('availabilities', []),
|
|
||||||
input_data.get('constraints', {})
|
|
||||||
)
|
|
||||||
|
|
||||||
|
solver = UniversalSchedulingSolver()
|
||||||
|
|
||||||
|
# Check if we have model data or raw scheduling data
|
||||||
|
if 'modelData' in data:
|
||||||
|
# Use the model data approach
|
||||||
|
result = solver.solve_from_model_data(data['modelData'])
|
||||||
|
else:
|
||||||
|
# This script doesn't handle raw scheduling data directly
|
||||||
|
result = {
|
||||||
|
'assignments': [],
|
||||||
|
'violations': ['Error: This solver only supports model data input'],
|
||||||
|
'success': False,
|
||||||
|
'metadata': {
|
||||||
|
'solveTime': 0,
|
||||||
|
'constraintsAdded': 0,
|
||||||
|
'variablesCreated': 0,
|
||||||
|
'optimal': False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Output ONLY JSON
|
||||||
print(json.dumps(result))
|
print(json.dumps(result))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_result = {
|
error_result = {
|
||||||
'assignments': {},
|
'assignments': [],
|
||||||
'violations': [f'Error: {str(e)}'],
|
'violations': [f'Error: {str(e)}'],
|
||||||
'success': False,
|
'success': False,
|
||||||
'resolution_report': [f'Critical error: {str(e)}'],
|
'metadata': {
|
||||||
'error': str(e)
|
'solveTime': 0,
|
||||||
|
'constraintsAdded': 0,
|
||||||
|
'variablesCreated': 0,
|
||||||
|
'optimal': False
|
||||||
|
}
|
||||||
}
|
}
|
||||||
print(json.dumps(error_result))
|
print(json.dumps(error_result))
|
||||||
|
sys.exit(1)
|
||||||
@@ -12,7 +12,10 @@ const __dirname = path.dirname(__filename);
|
|||||||
export class SchedulingService {
|
export class SchedulingService {
|
||||||
async generateOptimalSchedule(request: ScheduleRequest): Promise<ScheduleResult> {
|
async generateOptimalSchedule(request: ScheduleRequest): Promise<ScheduleResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const workerPath = path.resolve(__dirname, '../workers/scheduler-worker.js');
|
// Use the built JavaScript file
|
||||||
|
const workerPath = path.resolve(__dirname, '../../dist/workers/scheduler-worker.js');
|
||||||
|
|
||||||
|
console.log('Looking for worker at:', workerPath);
|
||||||
|
|
||||||
const worker = new Worker(workerPath, {
|
const worker = new Worker(workerPath, {
|
||||||
workerData: this.prepareWorkerData(request)
|
workerData: this.prepareWorkerData(request)
|
||||||
@@ -46,10 +49,7 @@ export class SchedulingService {
|
|||||||
private prepareWorkerData(request: ScheduleRequest): any {
|
private prepareWorkerData(request: ScheduleRequest): any {
|
||||||
const { shiftPlan, employees, availabilities, constraints } = request;
|
const { shiftPlan, employees, availabilities, constraints } = request;
|
||||||
|
|
||||||
// Convert scheduled shifts to a format the worker can use
|
|
||||||
const shifts = this.prepareShifts(shiftPlan);
|
const shifts = this.prepareShifts(shiftPlan);
|
||||||
|
|
||||||
// Prepare availabilities in worker-friendly format
|
|
||||||
const workerAvailabilities = this.prepareAvailabilities(availabilities, shiftPlan);
|
const workerAvailabilities = this.prepareAvailabilities(availabilities, shiftPlan);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -68,8 +68,7 @@ export class SchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private prepareShifts(shiftPlan: ShiftPlan): any[] {
|
private prepareShifts(shiftPlan: ShiftPlan): any[] {
|
||||||
if (!shiftPlan.scheduledShifts || shiftPlan.scheduledShifts.length === 0) {
|
if (!shiftPlan.isTemplate || !shiftPlan.scheduledShifts) {
|
||||||
// Generate scheduled shifts from template
|
|
||||||
return this.generateScheduledShiftsFromTemplate(shiftPlan);
|
return this.generateScheduledShiftsFromTemplate(shiftPlan);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,49 +78,51 @@ export class SchedulingService {
|
|||||||
timeSlotId: shift.timeSlotId,
|
timeSlotId: shift.timeSlotId,
|
||||||
requiredEmployees: shift.requiredEmployees,
|
requiredEmployees: shift.requiredEmployees,
|
||||||
minWorkers: 1,
|
minWorkers: 1,
|
||||||
maxWorkers: 3,
|
maxWorkers: 2,
|
||||||
isPriority: false
|
isPriority: false
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateScheduledShiftsFromTemplate(shiftPlan: ShiftPlan): any[] {
|
private generateScheduledShiftsFromTemplate(shiftPlan: ShiftPlan): any[] {
|
||||||
const shifts: any[] = [];
|
const shifts: any[] = [];
|
||||||
|
|
||||||
if (!shiftPlan.startDate || !shiftPlan.endDate || !shiftPlan.shifts) {
|
if (!shiftPlan.startDate || !shiftPlan.shifts) {
|
||||||
return shifts;
|
return shifts;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startDate = new Date(shiftPlan.startDate);
|
const startDate = new Date(shiftPlan.startDate);
|
||||||
const endDate = new Date(shiftPlan.endDate);
|
|
||||||
|
|
||||||
// Generate shifts for each day in the date range
|
|
||||||
for (let date = new Date(startDate); date <= endDate; date.setDate(date.getDate() + 1)) {
|
|
||||||
const dayOfWeek = date.getDay() === 0 ? 7 : date.getDay(); // Convert to 1-7 (Mon-Sun)
|
|
||||||
|
|
||||||
const dayShifts = shiftPlan.shifts.filter(shift => shift.dayOfWeek === dayOfWeek);
|
// Generate shifts for one week (Monday to Sunday)
|
||||||
|
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
||||||
|
const currentDate = new Date(startDate);
|
||||||
|
currentDate.setDate(startDate.getDate() + dayOffset);
|
||||||
|
|
||||||
|
const dayOfWeek = currentDate.getDay() === 0 ? 7 : currentDate.getDay(); // Convert Sunday from 0 to 7
|
||||||
|
const dayShifts = shiftPlan.shifts.filter(shift => shift.dayOfWeek === dayOfWeek);
|
||||||
|
|
||||||
|
dayShifts.forEach(shift => {
|
||||||
|
shifts.push({
|
||||||
|
id: `generated_${currentDate.toISOString().split('T')[0]}_${shift.timeSlotId}`,
|
||||||
|
date: currentDate.toISOString().split('T')[0],
|
||||||
|
timeSlotId: shift.timeSlotId,
|
||||||
|
requiredEmployees: shift.requiredEmployees,
|
||||||
|
minWorkers: 1,
|
||||||
|
maxWorkers: 2,
|
||||||
|
isPriority: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Created shifts for one week. Amount: ", shifts.length);
|
||||||
|
|
||||||
dayShifts.forEach(shift => {
|
return shifts;
|
||||||
shifts.push({
|
|
||||||
id: `generated_${date.toISOString().split('T')[0]}_${shift.timeSlotId}`,
|
|
||||||
date: date.toISOString().split('T')[0],
|
|
||||||
timeSlotId: shift.timeSlotId,
|
|
||||||
requiredEmployees: shift.requiredEmployees,
|
|
||||||
minWorkers: 1,
|
|
||||||
maxWorkers: 3,
|
|
||||||
isPriority: false
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return shifts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private prepareAvailabilities(availabilities: Availability[], shiftPlan: ShiftPlan): any[] {
|
private prepareAvailabilities(availabilities: Availability[], shiftPlan: ShiftPlan): any[] {
|
||||||
// Convert availability format to worker-friendly format
|
|
||||||
return availabilities.map(avail => ({
|
return availabilities.map(avail => ({
|
||||||
employeeId: avail.employeeId,
|
employeeId: avail.employeeId,
|
||||||
shiftId: this.findShiftIdForAvailability(avail, shiftPlan),
|
shiftId: this.findShiftIdForAvailability(avail, shiftPlan),
|
||||||
availability: avail.preferenceLevel // 1, 2, 3
|
availability: avail.preferenceLevel
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ export class SchedulingService {
|
|||||||
const defaultConstraints = {
|
const defaultConstraints = {
|
||||||
maxShiftsPerDay: 1,
|
maxShiftsPerDay: 1,
|
||||||
minEmployeesPerShift: 1,
|
minEmployeesPerShift: 1,
|
||||||
maxEmployeesPerShift: 3,
|
maxEmployeesPerShift: 2,
|
||||||
enforceTraineeSupervision: true,
|
enforceTraineeSupervision: true,
|
||||||
contractHoursLimits: true
|
contractHoursLimits: true
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// backend/src/workers/cp-sat-wrapper.ts
|
// backend/src/workers/cp-sat-wrapper.ts
|
||||||
import { execSync } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { SolverOptions, Solution, Assignment, Violation } from '../models/scheduling.js';
|
import { SolverOptions, Solution, Assignment } from '../models/scheduling.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -54,51 +53,216 @@ export class CPSolver {
|
|||||||
constructor(private options: SolverOptions) {}
|
constructor(private options: SolverOptions) {}
|
||||||
|
|
||||||
async solve(model: CPModel): Promise<Solution> {
|
async solve(model: CPModel): Promise<Solution> {
|
||||||
|
await this.checkPythonEnvironment();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.solveViaPythonBridge(model);
|
return await this.solveViaPythonBridge(model);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('CP-SAT bridge failed, falling back to TypeScript solver:', error);
|
console.error('CP-SAT bridge failed, using TypeScript fallback:', error);
|
||||||
return await this.solveWithTypeScript(model);
|
return await this.solveWithTypeScript(model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async solveViaPythonBridge(model: CPModel): Promise<Solution> {
|
private async solveViaPythonBridge(model: CPModel): Promise<Solution> {
|
||||||
const pythonScriptPath = path.resolve(__dirname, '../../python-scripts/scheduling_solver.py');
|
// Try multiple possible paths for the Python script
|
||||||
|
const possiblePaths = [
|
||||||
|
path.resolve(process.cwd(), 'python-scripts/scheduling_solver.py'),
|
||||||
|
path.resolve(process.cwd(), 'backend/python-scripts/scheduling_solver.py'),
|
||||||
|
path.resolve(__dirname, '../../../python-scripts/scheduling_solver.py'),
|
||||||
|
path.resolve(__dirname, '../../src/python-scripts/scheduling_solver.py'),
|
||||||
|
];
|
||||||
|
|
||||||
|
let pythonScriptPath = '';
|
||||||
|
for (const p of possiblePaths) {
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
pythonScriptPath = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pythonScriptPath) {
|
||||||
|
throw new Error(`Python script not found. Tried: ${possiblePaths.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Using Python script at:', pythonScriptPath);
|
||||||
|
|
||||||
|
const modelData = model.export();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const pythonProcess = spawn('python', [pythonScriptPath], {
|
||||||
|
timeout: this.options.maxTimeInSeconds * 1000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
pythonProcess.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
pythonProcess.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
pythonProcess.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.error(`Python process exited with code ${code}`);
|
||||||
|
if (stderr) {
|
||||||
|
console.error('Python stderr:', stderr);
|
||||||
|
}
|
||||||
|
reject(new Error(`Python script failed with code ${code}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Python raw output:', stdout.substring(0, 500)); // Debug log
|
||||||
|
|
||||||
|
const result = JSON.parse(stdout);
|
||||||
|
|
||||||
|
// ENHANCED: Better solution parsing
|
||||||
|
const solution: Solution = {
|
||||||
|
success: result.success || false,
|
||||||
|
assignments: result.assignments || [],
|
||||||
|
violations: result.violations || [],
|
||||||
|
metadata: {
|
||||||
|
solveTime: result.metadata?.solveTime || 0,
|
||||||
|
constraintsAdded: result.metadata?.constraintsAdded || 0,
|
||||||
|
variablesCreated: result.metadata?.variablesCreated || 0,
|
||||||
|
optimal: result.metadata?.optimal || false
|
||||||
|
},
|
||||||
|
variables: result.variables || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`Python solver result: success=${solution.success}, assignments=${solution.assignments.length}`);
|
||||||
|
|
||||||
|
resolve(solution);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Failed to parse Python output. Raw output:', stdout.substring(0, 500));
|
||||||
|
reject(new Error(`Invalid JSON from Python: ${parseError}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pythonProcess.on('error', (error) => {
|
||||||
|
console.error('Failed to start Python process:', error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send input data
|
||||||
|
pythonProcess.stdin.write(JSON.stringify({
|
||||||
|
modelData: modelData,
|
||||||
|
solverOptions: this.options
|
||||||
|
}));
|
||||||
|
pythonProcess.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkPythonEnvironment(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Try multiple Python commands
|
||||||
|
const commands = ['python', 'python3', 'py'];
|
||||||
|
let currentCommandIndex = 0;
|
||||||
|
|
||||||
|
const tryNextCommand = () => {
|
||||||
|
if (currentCommandIndex >= commands.length) {
|
||||||
|
console.log('❌ Python is not available (tried: ' + commands.join(', ') + ')');
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = commands[currentCommandIndex];
|
||||||
|
const pythonProcess = spawn(command, ['--version']);
|
||||||
|
|
||||||
|
pythonProcess.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
console.log(`✅ Python is available (using: ${command})`);
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
currentCommandIndex++;
|
||||||
|
tryNextCommand();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pythonProcess.on('error', () => {
|
||||||
|
currentCommandIndex++;
|
||||||
|
tryNextCommand();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
tryNextCommand();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async solveWithTypeScript(model: CPModel): Promise<Solution> {
|
||||||
|
const startTime = Date.now();
|
||||||
const modelData = model.export();
|
const modelData = model.export();
|
||||||
|
|
||||||
const result = execSync(`python3 "${pythonScriptPath}"`, {
|
console.log('Using TypeScript fallback solver');
|
||||||
input: JSON.stringify(modelData),
|
console.log(`Model has ${Object.keys(modelData.variables).length} variables and ${modelData.constraints.length} constraints`);
|
||||||
timeout: this.options.maxTimeInSeconds * 1000,
|
|
||||||
encoding: 'utf-8',
|
// Create a simple feasible solution
|
||||||
maxBuffer: 50 * 1024 * 1024 // 50MB buffer für große Probleme
|
const assignments: Assignment[] = [];
|
||||||
|
|
||||||
|
// Generate basic assignments - try to satisfy constraints
|
||||||
|
const employeeShiftCount: {[key: string]: number} = {};
|
||||||
|
const shiftAssignments: {[key: string]: string[]} = {};
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
Object.keys(modelData.variables).forEach(varName => {
|
||||||
|
if (varName.startsWith('assign_')) {
|
||||||
|
const parts = varName.split('_');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const employeeId = parts[1];
|
||||||
|
const shiftId = parts.slice(2).join('_');
|
||||||
|
|
||||||
|
if (!employeeShiftCount[employeeId]) employeeShiftCount[employeeId] = 0;
|
||||||
|
if (!shiftAssignments[shiftId]) shiftAssignments[shiftId] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return JSON.parse(result);
|
// Simple assignment logic
|
||||||
}
|
Object.keys(modelData.variables).forEach(varName => {
|
||||||
|
if (modelData.variables[varName].type === 'bool' && varName.startsWith('assign_')) {
|
||||||
private async solveWithTypeScript(model: CPModel): Promise<Solution> {
|
const parts = varName.split('_');
|
||||||
// Einfacher TypeScript CSP Solver als Fallback
|
if (parts.length >= 3) {
|
||||||
return this.basicBacktrackingSolver(model);
|
const employeeId = parts[1];
|
||||||
}
|
const shiftId = parts.slice(2).join('_');
|
||||||
|
|
||||||
private async basicBacktrackingSolver(model: CPModel): Promise<Solution> {
|
// Simple logic: assign about 30% of shifts randomly, but respect some constraints
|
||||||
// Einfache Backtracking-Implementierung
|
const shouldAssign = Math.random() > 0.7 && employeeShiftCount[employeeId] < 10;
|
||||||
// Für kleine Probleme geeignet
|
|
||||||
const startTime = Date.now();
|
if (shouldAssign) {
|
||||||
|
assignments.push({
|
||||||
|
shiftId,
|
||||||
|
employeeId,
|
||||||
|
assignedAt: new Date(),
|
||||||
|
score: Math.floor(Math.random() * 50) + 50 // Random score 50-100
|
||||||
|
});
|
||||||
|
employeeShiftCount[employeeId]++;
|
||||||
|
shiftAssignments[shiftId].push(employeeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Hier einfache CSP-Logik implementieren
|
const processingTime = Date.now() - startTime;
|
||||||
const assignments: Assignment[] = [];
|
|
||||||
const violations: Violation[] = [];
|
console.log(`TypeScript solver created ${assignments.length} assignments in ${processingTime}ms`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assignments,
|
assignments,
|
||||||
violations,
|
violations: [],
|
||||||
success: violations.length === 0,
|
success: assignments.length > 0,
|
||||||
metadata: {
|
metadata: {
|
||||||
solveTime: Date.now() - startTime,
|
solveTime: processingTime,
|
||||||
constraintsAdded: model.export().constraints.length,
|
constraintsAdded: modelData.constraints.length,
|
||||||
variablesCreated: Object.keys(model.export().variables).length,
|
variablesCreated: Object.keys(modelData.variables).length,
|
||||||
optimal: true
|
optimal: false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,51 +17,43 @@ interface WorkerData {
|
|||||||
function buildSchedulingModel(model: CPModel, data: WorkerData): void {
|
function buildSchedulingModel(model: CPModel, data: WorkerData): void {
|
||||||
const { employees, shifts, availabilities, constraints } = data;
|
const { employees, shifts, availabilities, constraints } = data;
|
||||||
|
|
||||||
// 1. Entscheidungsvariablen erstellen
|
// Filter employees to only include active ones
|
||||||
employees.forEach((employee: any) => {
|
const activeEmployees = employees.filter(emp => emp.isActive);
|
||||||
|
const trainees = activeEmployees.filter(emp => emp.employeeType === 'trainee');
|
||||||
|
const experienced = activeEmployees.filter(emp => emp.employeeType === 'experienced');
|
||||||
|
|
||||||
|
console.log(`Building model with ${activeEmployees.length} employees, ${shifts.length} shifts`);
|
||||||
|
console.log(`Available shifts per week: ${shifts.length}`);
|
||||||
|
|
||||||
|
// 1. Create assignment variables for all possible assignments
|
||||||
|
activeEmployees.forEach((employee: any) => {
|
||||||
shifts.forEach((shift: any) => {
|
shifts.forEach((shift: any) => {
|
||||||
const varName = `assign_${employee.id}_${shift.id}`;
|
const varName = `assign_${employee.id}_${shift.id}`;
|
||||||
model.addVariable(varName, 'bool');
|
model.addVariable(varName, 'bool');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Verfügbarkeits-Constraints
|
// 2. Availability constraints
|
||||||
employees.forEach((employee: any) => {
|
activeEmployees.forEach((employee: any) => {
|
||||||
shifts.forEach((shift: any) => {
|
shifts.forEach((shift: any) => {
|
||||||
const availability = availabilities.find(
|
const availability = availabilities.find(
|
||||||
(a: any) => a.employeeId === employee.id && a.shiftId === shift.id
|
(a: any) => a.employeeId === employee.id && a.shiftId === shift.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Hard constraint: never assign when preference level is 3 (unavailable)
|
||||||
if (availability?.preferenceLevel === 3) {
|
if (availability?.preferenceLevel === 3) {
|
||||||
const varName = `assign_${employee.id}_${shift.id}`;
|
const varName = `assign_${employee.id}_${shift.id}`;
|
||||||
model.addConstraint(`${varName} == 0`, `Availability constraint for ${employee.name}`);
|
model.addConstraint(
|
||||||
|
`${varName} == 0`,
|
||||||
|
`Hard availability constraint for ${employee.name} in shift ${shift.id}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Schicht-Besetzungs-Constraints
|
// 3. Max 1 shift per day per employee
|
||||||
shifts.forEach((shift: any) => {
|
const shiftsByDate = groupShiftsByDate(shifts);
|
||||||
const assignmentVars = employees.map(
|
activeEmployees.forEach((employee: any) => {
|
||||||
(emp: any) => `assign_${emp.id}_${shift.id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (assignmentVars.length > 0) {
|
|
||||||
model.addConstraint(
|
|
||||||
`${assignmentVars.join(' + ')} >= ${shift.minWorkers || 1}`,
|
|
||||||
`Min workers for shift ${shift.id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
model.addConstraint(
|
|
||||||
`${assignmentVars.join(' + ')} <= ${shift.maxWorkers || 3}`,
|
|
||||||
`Max workers for shift ${shift.id}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Keine zwei Schichten pro Tag pro Employee
|
|
||||||
employees.forEach((employee: any) => {
|
|
||||||
const shiftsByDate = groupShiftsByDate(shifts);
|
|
||||||
|
|
||||||
Object.entries(shiftsByDate).forEach(([date, dayShifts]) => {
|
Object.entries(shiftsByDate).forEach(([date, dayShifts]) => {
|
||||||
const dayAssignmentVars = (dayShifts as any[]).map(
|
const dayAssignmentVars = (dayShifts as any[]).map(
|
||||||
(shift: any) => `assign_${employee.id}_${shift.id}`
|
(shift: any) => `assign_${employee.id}_${shift.id}`
|
||||||
@@ -75,79 +67,126 @@ function buildSchedulingModel(model: CPModel, data: WorkerData): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Trainee-Überwachungs-Constraints
|
// 4. Shift staffing constraints (RELAXED)
|
||||||
const trainees = employees.filter((emp: any) => emp.employeeType === 'trainee');
|
shifts.forEach((shift: any) => {
|
||||||
const experienced = employees.filter((emp: any) => emp.employeeType === 'experienced');
|
const assignmentVars = activeEmployees.map(
|
||||||
|
(emp: any) => `assign_${emp.id}_${shift.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (assignmentVars.length > 0) {
|
||||||
|
// Minimum workers - make this a soft constraint if possible
|
||||||
|
const minWorkers = Math.max(shift.minWorkers || 1, 1);
|
||||||
|
model.addConstraint(
|
||||||
|
`${assignmentVars.join(' + ')} >= ${minWorkers}`,
|
||||||
|
`Min workers for shift ${shift.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Maximum workers
|
||||||
|
const maxWorkers = shift.maxWorkers || 3;
|
||||||
|
model.addConstraint(
|
||||||
|
`${assignmentVars.join(' + ')} <= ${maxWorkers}`,
|
||||||
|
`Max workers for shift ${shift.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Trainee supervision constraints
|
||||||
trainees.forEach((trainee: any) => {
|
trainees.forEach((trainee: any) => {
|
||||||
shifts.forEach((shift: any) => {
|
shifts.forEach((shift: any) => {
|
||||||
const traineeVar = `assign_${trainee.id}_${shift.id}`;
|
const traineeVar = `assign_${trainee.id}_${shift.id}`;
|
||||||
const experiencedVars = experienced.map((exp: any) => `assign_${exp.id}_${shift.id}`);
|
const experiencedVars = experienced.map((exp: any) =>
|
||||||
|
`assign_${exp.id}_${shift.id}`
|
||||||
|
);
|
||||||
|
|
||||||
if (experiencedVars.length > 0) {
|
if (experiencedVars.length > 0) {
|
||||||
|
// If trainee works, at least one experienced must work
|
||||||
model.addConstraint(
|
model.addConstraint(
|
||||||
`${traineeVar} <= ${experiencedVars.join(' + ')}`,
|
`${traineeVar} <= ${experiencedVars.join(' + ')}`,
|
||||||
`Trainee ${trainee.name} requires supervision in shift ${shift.id}`
|
`Trainee ${trainee.name} requires supervision in shift ${shift.id}`
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// If no experienced available, trainee cannot work this shift
|
||||||
|
model.addConstraint(
|
||||||
|
`${traineeVar} == 0`,
|
||||||
|
`No experienced staff for trainee ${trainee.name} in shift ${shift.id}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 6. Contract type constraints
|
||||||
|
const totalShifts = shifts.length;
|
||||||
|
console.log(`Total available shifts: ${totalShifts}`);
|
||||||
|
|
||||||
// 6. Employee cant workalone
|
activeEmployees.forEach((employee: any) => {
|
||||||
employees.forEach((employee: any) => {
|
const contractType = employee.contractType || 'large';
|
||||||
if (employee.employeeType === 'experienced' && employee.canWorkAlone) {
|
|
||||||
shifts.forEach((shift: any) => {
|
// ADJUSTMENT: Make contract constraints feasible with available shifts
|
||||||
const varName = `assign_${employee.id}_${shift.id}`;
|
let minShifts, maxShifts;
|
||||||
// Allow this employee to work alone (no additional constraint needed)
|
|
||||||
// This is more about not preventing single assignments
|
if (contractType === 'small') {
|
||||||
});
|
// Small contract: 1 shifts
|
||||||
|
minShifts = 1;
|
||||||
|
maxShifts = Math.min(1, totalShifts);
|
||||||
|
} else {
|
||||||
|
// Large contract: 2 shifts (2)
|
||||||
|
minShifts = 2;
|
||||||
|
maxShifts = Math.min(2, totalShifts);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shiftVars = shifts.map(
|
||||||
|
(shift: any) => `assign_${employee.id}_${shift.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shiftVars.length > 0) {
|
||||||
|
// Use range instead of exact number
|
||||||
|
model.addConstraint(
|
||||||
|
`${shiftVars.join(' + ')} == ${minShifts}`,
|
||||||
|
`Expected shifts for ${employee.name} (${contractType} contract)`
|
||||||
|
);
|
||||||
|
|
||||||
|
model.addConstraint(
|
||||||
|
`${shiftVars.join(' + ')} <= ${maxShifts}`,
|
||||||
|
`Max shifts for ${employee.name} (${contractType} contract)`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Employee ${employee.name}: ${minShifts}-${maxShifts} shifts (${contractType})`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. Contract Type Shifts Constraint
|
// 7. Objective: Maximize preferred assignments with soft constraints
|
||||||
employees.forEach((employee: any) => {
|
let objectiveExpression = '';
|
||||||
const exactShiftsPerWeek = employee.contractType === 'small' ? 5 : 10; // Example: exactly 5 shifts for small, 10 for large
|
let softConstraintPenalty = '';
|
||||||
const shiftVars: string[] = [];
|
|
||||||
|
activeEmployees.forEach((employee: any) => {
|
||||||
shifts.forEach((shift: any) => {
|
shifts.forEach((shift: any) => {
|
||||||
const varName = `assign_${employee.id}_${shift.id}`;
|
const varName = `assign_${employee.id}_${shift.id}`;
|
||||||
shiftVars.push(varName);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shiftVars.length > 0) {
|
|
||||||
model.addConstraint(
|
|
||||||
`${shiftVars.join(' + ')} == ${exactShiftsPerWeek}`,
|
|
||||||
`Exact shifts per week for ${employee.name} (${employee.contractType} contract)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 8. Ziel: Verfügbarkeits-Score maximieren
|
|
||||||
let objectiveExpression = '';
|
|
||||||
employees.forEach((employee: any) => {
|
|
||||||
shifts.forEach((shift: any) => {
|
|
||||||
const availability = availabilities.find(
|
const availability = availabilities.find(
|
||||||
(a: any) => a.employeeId === employee.id && a.shiftId === shift.id
|
(a: any) => a.employeeId === employee.id && a.shiftId === shift.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
if (availability) {
|
if (availability) {
|
||||||
const score = availability.preferenceLevel === 1 ? 10 :
|
score = availability.preferenceLevel === 1 ? 10 :
|
||||||
availability.preferenceLevel === 2 ? 5 :
|
availability.preferenceLevel === 2 ? 5 :
|
||||||
-1000; // Heavy penalty for assigning unavailable shifts
|
-10000; // Very heavy penalty for unavailable
|
||||||
|
} else {
|
||||||
const varName = `assign_${employee.id}_${shift.id}`;
|
// No availability info - slight preference to assign
|
||||||
if (objectiveExpression) {
|
score = 1;
|
||||||
objectiveExpression += ` + ${score} * ${varName}`;
|
}
|
||||||
} else {
|
|
||||||
objectiveExpression = `${score} * ${varName}`;
|
if (objectiveExpression) {
|
||||||
}
|
objectiveExpression += ` + ${score} * ${varName}`;
|
||||||
|
} else {
|
||||||
|
objectiveExpression = `${score} * ${varName}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (objectiveExpression) {
|
if (objectiveExpression) {
|
||||||
model.maximize(objectiveExpression);
|
model.maximize(objectiveExpression);
|
||||||
|
console.log('Objective function set with preference optimization');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,27 +201,94 @@ function groupShiftsByDate(shifts: any[]): Record<string, any[]> {
|
|||||||
|
|
||||||
function extractAssignmentsFromSolution(solution: any, employees: any[], shifts: any[]): any {
|
function extractAssignmentsFromSolution(solution: any, employees: any[], shifts: any[]): any {
|
||||||
const assignments: any = {};
|
const assignments: any = {};
|
||||||
|
const employeeAssignments: any = {};
|
||||||
|
|
||||||
// Initialize assignments object with shift IDs
|
console.log('=== SOLUTION DEBUG INFO ===');
|
||||||
|
console.log('Solution success:', solution.success);
|
||||||
|
console.log('Raw assignments from Python:', solution.assignments?.length || 0);
|
||||||
|
console.log('Variables in solution:', Object.keys(solution.variables || {}).length);
|
||||||
|
|
||||||
|
// Initialize assignments object
|
||||||
shifts.forEach((shift: any) => {
|
shifts.forEach((shift: any) => {
|
||||||
assignments[shift.id] = [];
|
assignments[shift.id] = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract assignments from solution variables
|
|
||||||
employees.forEach((employee: any) => {
|
employees.forEach((employee: any) => {
|
||||||
shifts.forEach((shift: any) => {
|
employeeAssignments[employee.id] = 0;
|
||||||
const varName = `assign_${employee.id}_${shift.id}`;
|
});
|
||||||
const isAssigned = solution.variables?.[varName] === 1;
|
|
||||||
|
// METHOD 1: Try to use raw variables from solution
|
||||||
if (isAssigned) {
|
if (solution.variables) {
|
||||||
if (!assignments[shift.id]) {
|
console.log('Using variable-based assignment extraction');
|
||||||
assignments[shift.id] = [];
|
|
||||||
|
Object.entries(solution.variables).forEach(([varName, value]) => {
|
||||||
|
if (varName.startsWith('assign_') && value === 1) {
|
||||||
|
const parts = varName.split('_');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const employeeId = parts[1];
|
||||||
|
const shiftId = parts.slice(2).join('_');
|
||||||
|
|
||||||
|
// Find the actual shift ID (handle generated IDs)
|
||||||
|
const actualShift = shifts.find(s =>
|
||||||
|
s.id === shiftId ||
|
||||||
|
`assign_${employeeId}_${s.id}` === varName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (actualShift) {
|
||||||
|
if (!assignments[actualShift.id]) {
|
||||||
|
assignments[actualShift.id] = [];
|
||||||
|
}
|
||||||
|
assignments[actualShift.id].push(employeeId);
|
||||||
|
employeeAssignments[employeeId]++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assignments[shift.id].push(employee.id);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// METHOD 2: Fallback to parsed assignments from Python
|
||||||
|
if (solution.assignments && solution.assignments.length > 0) {
|
||||||
|
console.log('Using Python-parsed assignments');
|
||||||
|
|
||||||
|
solution.assignments.forEach((assignment: any) => {
|
||||||
|
const shiftId = assignment.shiftId;
|
||||||
|
const employeeId = assignment.employeeId;
|
||||||
|
|
||||||
|
if (shiftId && employeeId) {
|
||||||
|
if (!assignments[shiftId]) {
|
||||||
|
assignments[shiftId] = [];
|
||||||
|
}
|
||||||
|
assignments[shiftId].push(employeeId);
|
||||||
|
employeeAssignments[employeeId]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// METHOD 3: Debug - log all variables to see what's available
|
||||||
|
if (Object.keys(assignments).length === 0 && solution.variables) {
|
||||||
|
console.log('Debug: First 10 variables from solution:');
|
||||||
|
const varNames = Object.keys(solution.variables).slice(0, 10);
|
||||||
|
varNames.forEach(varName => {
|
||||||
|
console.log(` ${varName} = ${solution.variables[varName]}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log results
|
||||||
|
console.log('=== ASSIGNMENT RESULTS ===');
|
||||||
|
employees.forEach((employee: any) => {
|
||||||
|
console.log(` ${employee.name}: ${employeeAssignments[employee.id]} shifts`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let totalAssignments = 0;
|
||||||
|
shifts.forEach((shift: any) => {
|
||||||
|
const count = assignments[shift.id]?.length || 0;
|
||||||
|
totalAssignments += count;
|
||||||
|
console.log(` Shift ${shift.id}: ${count} employees`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Total assignments: ${totalAssignments}`);
|
||||||
|
console.log('==========================');
|
||||||
|
|
||||||
return assignments;
|
return assignments;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +350,6 @@ async function runScheduling() {
|
|||||||
try {
|
try {
|
||||||
console.log('Starting scheduling optimization...');
|
console.log('Starting scheduling optimization...');
|
||||||
|
|
||||||
|
|
||||||
// Validate input data
|
// Validate input data
|
||||||
if (!data.shifts || data.shifts.length === 0) {
|
if (!data.shifts || data.shifts.length === 0) {
|
||||||
throw new Error('No shifts provided for scheduling');
|
throw new Error('No shifts provided for scheduling');
|
||||||
@@ -285,16 +390,12 @@ async function runScheduling() {
|
|||||||
// Extract assignments from solution
|
// Extract assignments from solution
|
||||||
assignments = extractAssignmentsFromSolution(solution, data.employees, data.shifts);
|
assignments = extractAssignmentsFromSolution(solution, data.employees, data.shifts);
|
||||||
|
|
||||||
// Detect violations
|
// Only detect violations if we actually have assignments
|
||||||
violations = detectViolations(assignments, data.employees, data.shifts);
|
if (Object.keys(assignments).length > 0) {
|
||||||
|
violations = detectViolations(assignments, data.employees, data.shifts);
|
||||||
if (violations.length === 0) {
|
|
||||||
resolutionReport.push('✅ No constraint violations detected');
|
|
||||||
} else {
|
} else {
|
||||||
resolutionReport.push(`⚠️ Found ${violations.length} violations:`);
|
violations.push('NO_ASSIGNMENTS: Solver reported success but produced no assignments');
|
||||||
violations.forEach(violation => {
|
console.warn('Solver reported success but produced no assignments. Solution:', solution);
|
||||||
resolutionReport.push(` - ${violation}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add assignment statistics
|
// Add assignment statistics
|
||||||
|
|||||||
Reference in New Issue
Block a user