init
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package org.acme.employeescheduling.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import ai.timefold.solver.core.api.domain.lookup.PlanningId;
|
||||
|
||||
public class Employee {
|
||||
@PlanningId
|
||||
private String name;
|
||||
private Set<String> skills;
|
||||
|
||||
private Set<LocalDate> unavailableDates;
|
||||
private Set<LocalDate> undesiredDates;
|
||||
private Set<LocalDate> desiredDates;
|
||||
|
||||
public Employee() {
|
||||
|
||||
}
|
||||
|
||||
public Employee(String name, Set<String> skills,
|
||||
Set<LocalDate> unavailableDates, Set<LocalDate> undesiredDates, Set<LocalDate> desiredDates) {
|
||||
this.name = name;
|
||||
this.skills = skills;
|
||||
this.unavailableDates = unavailableDates;
|
||||
this.undesiredDates = undesiredDates;
|
||||
this.desiredDates = desiredDates;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Set<String> getSkills() {
|
||||
return skills;
|
||||
}
|
||||
|
||||
public void setSkills(Set<String> skills) {
|
||||
this.skills = skills;
|
||||
}
|
||||
|
||||
public Set<LocalDate> getUnavailableDates() {
|
||||
return unavailableDates;
|
||||
}
|
||||
|
||||
public void setUnavailableDates(Set<LocalDate> unavailableDates) {
|
||||
this.unavailableDates = unavailableDates;
|
||||
}
|
||||
|
||||
public Set<LocalDate> getUndesiredDates() {
|
||||
return undesiredDates;
|
||||
}
|
||||
|
||||
public void setUndesiredDates(Set<LocalDate> undesiredDates) {
|
||||
this.undesiredDates = undesiredDates;
|
||||
}
|
||||
|
||||
public Set<LocalDate> getDesiredDates() {
|
||||
return desiredDates;
|
||||
}
|
||||
|
||||
public void setDesiredDates(Set<LocalDate> desiredDates) {
|
||||
this.desiredDates = desiredDates;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof Employee employee)) {
|
||||
return false;
|
||||
}
|
||||
return Objects.equals(getName(), employee.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return getName().hashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.acme.employeescheduling.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
|
||||
import ai.timefold.solver.core.api.domain.solution.PlanningScore;
|
||||
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
|
||||
import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty;
|
||||
import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
|
||||
import ai.timefold.solver.core.api.score.buildin.hardsoftbigdecimal.HardSoftBigDecimalScore;
|
||||
import ai.timefold.solver.core.api.solver.SolverStatus;
|
||||
|
||||
@PlanningSolution
|
||||
public class EmployeeSchedule {
|
||||
|
||||
@ProblemFactCollectionProperty
|
||||
@ValueRangeProvider
|
||||
private List<Employee> employees;
|
||||
|
||||
@PlanningEntityCollectionProperty
|
||||
private List<Shift> shifts;
|
||||
|
||||
@PlanningScore
|
||||
private HardSoftBigDecimalScore score;
|
||||
|
||||
private SolverStatus solverStatus;
|
||||
|
||||
// No-arg constructor required for Timefold
|
||||
public EmployeeSchedule() {}
|
||||
|
||||
public EmployeeSchedule(List<Employee> employees, List<Shift> shifts) {
|
||||
this.employees = employees;
|
||||
this.shifts = shifts;
|
||||
}
|
||||
|
||||
public EmployeeSchedule(HardSoftBigDecimalScore score, SolverStatus solverStatus) {
|
||||
this.score = score;
|
||||
this.solverStatus = solverStatus;
|
||||
}
|
||||
|
||||
public List<Employee> getEmployees() {
|
||||
return employees;
|
||||
}
|
||||
|
||||
public void setEmployees(List<Employee> employees) {
|
||||
this.employees = employees;
|
||||
}
|
||||
|
||||
public List<Shift> getShifts() {
|
||||
return shifts;
|
||||
}
|
||||
|
||||
public void setShifts(List<Shift> shifts) {
|
||||
this.shifts = shifts;
|
||||
}
|
||||
|
||||
public HardSoftBigDecimalScore getScore() {
|
||||
return score;
|
||||
}
|
||||
|
||||
public void setScore(HardSoftBigDecimalScore score) {
|
||||
this.score = score;
|
||||
}
|
||||
|
||||
public SolverStatus getSolverStatus() {
|
||||
return solverStatus;
|
||||
}
|
||||
|
||||
public void setSolverStatus(SolverStatus solverStatus) {
|
||||
this.solverStatus = solverStatus;
|
||||
}
|
||||
}
|
||||
133
src/main/java/org/acme/employeescheduling/domain/Shift.java
Normal file
133
src/main/java/org/acme/employeescheduling/domain/Shift.java
Normal file
@@ -0,0 +1,133 @@
|
||||
package org.acme.employeescheduling.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Objects;
|
||||
|
||||
import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
|
||||
import ai.timefold.solver.core.api.domain.lookup.PlanningId;
|
||||
import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
|
||||
|
||||
@PlanningEntity
|
||||
public class Shift {
|
||||
@PlanningId
|
||||
private String id;
|
||||
|
||||
private LocalDateTime start;
|
||||
private LocalDateTime end;
|
||||
|
||||
private String location;
|
||||
private String requiredSkill;
|
||||
|
||||
@PlanningVariable
|
||||
private Employee employee;
|
||||
|
||||
public Shift() {
|
||||
}
|
||||
|
||||
public Shift(LocalDateTime start, LocalDateTime end, String location, String requiredSkill) {
|
||||
this(start, end, location, requiredSkill, null);
|
||||
}
|
||||
|
||||
public Shift(LocalDateTime start, LocalDateTime end, String location, String requiredSkill, Employee employee) {
|
||||
this(null, start, end, location, requiredSkill, employee);
|
||||
}
|
||||
|
||||
public Shift(String id, LocalDateTime start, LocalDateTime end, String location, String requiredSkill, Employee employee) {
|
||||
this.id = id;
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
this.location = location;
|
||||
this.requiredSkill = requiredSkill;
|
||||
this.employee = employee;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public LocalDateTime getStart() {
|
||||
return start;
|
||||
}
|
||||
|
||||
public void setStart(LocalDateTime start) {
|
||||
this.start = start;
|
||||
}
|
||||
|
||||
public LocalDateTime getEnd() {
|
||||
return end;
|
||||
}
|
||||
|
||||
public void setEnd(LocalDateTime end) {
|
||||
this.end = end;
|
||||
}
|
||||
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
public String getRequiredSkill() {
|
||||
return requiredSkill;
|
||||
}
|
||||
|
||||
public void setRequiredSkill(String requiredSkill) {
|
||||
this.requiredSkill = requiredSkill;
|
||||
}
|
||||
|
||||
public Employee getEmployee() {
|
||||
return employee;
|
||||
}
|
||||
|
||||
public void setEmployee(Employee employee) {
|
||||
this.employee = employee;
|
||||
}
|
||||
|
||||
public boolean isOverlappingWithDate(LocalDate date) {
|
||||
return getStart().toLocalDate().equals(date) || getEnd().toLocalDate().equals(date);
|
||||
}
|
||||
|
||||
public int getOverlappingDurationInMinutes(LocalDate date) {
|
||||
LocalDateTime startDateTime = LocalDateTime.of(date, LocalTime.MIN);
|
||||
LocalDateTime endDateTime = LocalDateTime.of(date, LocalTime.MAX);
|
||||
return getOverlappingDurationInMinutes(startDateTime, endDateTime, getStart(), getEnd());
|
||||
}
|
||||
|
||||
private int getOverlappingDurationInMinutes(LocalDateTime firstStartDateTime, LocalDateTime firstEndDateTime,
|
||||
LocalDateTime secondStartDateTime, LocalDateTime secondEndDateTime) {
|
||||
LocalDateTime maxStartTime = firstStartDateTime.isAfter(secondStartDateTime) ? firstStartDateTime : secondStartDateTime;
|
||||
LocalDateTime minEndTime = firstEndDateTime.isBefore(secondEndDateTime) ? firstEndDateTime : secondEndDateTime;
|
||||
long minutes = maxStartTime.until(minEndTime, ChronoUnit.MINUTES);
|
||||
return minutes > 0 ? (int) minutes : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return location + " " + start + "-" + end;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof Shift shift)) {
|
||||
return false;
|
||||
}
|
||||
return Objects.equals(getId(), shift.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return getId().hashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package org.acme.employeescheduling.rest;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import org.acme.employeescheduling.domain.Employee;
|
||||
import org.acme.employeescheduling.domain.EmployeeSchedule;
|
||||
import org.acme.employeescheduling.domain.Shift;
|
||||
|
||||
@ApplicationScoped
|
||||
public class DemoDataGenerator {
|
||||
public enum DemoData {
|
||||
SMALL(new DemoDataParameters(
|
||||
List.of("Ambulatory care", "Critical care", "Pediatric care"),
|
||||
List.of("Doctor", "Nurse"),
|
||||
List.of("Anaesthetics", "Cardiology"),
|
||||
14,
|
||||
15,
|
||||
List.of(new CountDistribution(1, 3),
|
||||
new CountDistribution(2, 1)
|
||||
),
|
||||
List.of(new CountDistribution(1, 0.9),
|
||||
new CountDistribution(2, 0.1)
|
||||
),
|
||||
List.of(new CountDistribution(1, 4),
|
||||
new CountDistribution(2, 3),
|
||||
new CountDistribution(3, 2),
|
||||
new CountDistribution(4, 1)
|
||||
),
|
||||
0
|
||||
)),
|
||||
LARGE(new DemoDataParameters(
|
||||
List.of("Ambulatory care",
|
||||
"Neurology",
|
||||
"Critical care",
|
||||
"Pediatric care",
|
||||
"Surgery",
|
||||
"Radiology",
|
||||
"Outpatient"),
|
||||
List.of("Doctor", "Nurse"),
|
||||
List.of("Anaesthetics", "Cardiology", "Radiology"),
|
||||
28,
|
||||
50,
|
||||
List.of(new CountDistribution(1, 3),
|
||||
new CountDistribution(2, 1)
|
||||
),
|
||||
List.of(new CountDistribution(1, 0.5),
|
||||
new CountDistribution(2, 0.3),
|
||||
new CountDistribution(3, 0.2)
|
||||
),
|
||||
List.of(new CountDistribution(5, 4),
|
||||
new CountDistribution(10, 3),
|
||||
new CountDistribution(15, 2),
|
||||
new CountDistribution(20, 1)
|
||||
),
|
||||
0
|
||||
));
|
||||
|
||||
private final DemoDataParameters parameters;
|
||||
|
||||
DemoData(DemoDataParameters parameters) {
|
||||
this.parameters = parameters;
|
||||
}
|
||||
|
||||
public DemoDataParameters getParameters() {
|
||||
return parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public record CountDistribution(int count, double weight) {}
|
||||
|
||||
public record DemoDataParameters(List<String> locations,
|
||||
List<String> requiredSkills,
|
||||
List<String> optionalSkills,
|
||||
int daysInSchedule,
|
||||
int employeeCount,
|
||||
List<CountDistribution> optionalSkillDistribution,
|
||||
List<CountDistribution> shiftCountDistribution,
|
||||
List<CountDistribution> availabilityCountDistribution,
|
||||
int randomSeed) {}
|
||||
|
||||
private static final String[] FIRST_NAMES = { "Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay" };
|
||||
private static final String[] LAST_NAMES = { "Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt" };
|
||||
private static final Duration SHIFT_LENGTH = Duration.ofHours(8);
|
||||
private static final LocalTime MORNING_SHIFT_START_TIME = LocalTime.of(6, 0);
|
||||
private static final LocalTime DAY_SHIFT_START_TIME = LocalTime.of(9, 0);
|
||||
private static final LocalTime AFTERNOON_SHIFT_START_TIME = LocalTime.of(14, 0);
|
||||
private static final LocalTime NIGHT_SHIFT_START_TIME = LocalTime.of(22, 0);
|
||||
|
||||
static final LocalTime[][] SHIFT_START_TIMES_COMBOS = {
|
||||
{ MORNING_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME },
|
||||
{ MORNING_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME, NIGHT_SHIFT_START_TIME },
|
||||
{ MORNING_SHIFT_START_TIME, DAY_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME, NIGHT_SHIFT_START_TIME },
|
||||
};
|
||||
|
||||
Map<String, List<LocalTime>> locationToShiftStartTimeListMap = new HashMap<>();
|
||||
|
||||
public EmployeeSchedule generateDemoData(DemoData demoData) {
|
||||
return generateDemoData(demoData.getParameters());
|
||||
}
|
||||
|
||||
public EmployeeSchedule generateDemoData(DemoDataParameters parameters) {
|
||||
EmployeeSchedule employeeSchedule = new EmployeeSchedule();
|
||||
|
||||
LocalDate startDate = LocalDate.now().with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY));
|
||||
|
||||
Random random = new Random(parameters.randomSeed);
|
||||
|
||||
int shiftTemplateIndex = 0;
|
||||
for (String location : parameters.locations) {
|
||||
locationToShiftStartTimeListMap.put(location, List.of(SHIFT_START_TIMES_COMBOS[shiftTemplateIndex]));
|
||||
shiftTemplateIndex = (shiftTemplateIndex + 1) % SHIFT_START_TIMES_COMBOS.length;
|
||||
}
|
||||
|
||||
List<String> namePermutations = joinAllCombinations(FIRST_NAMES, LAST_NAMES);
|
||||
Collections.shuffle(namePermutations, random);
|
||||
|
||||
List<Employee> employees = new ArrayList<>();
|
||||
for (int i = 0; i < parameters.employeeCount; i++) {
|
||||
Set<String> skills = pickSubset(parameters.optionalSkills, random, parameters.optionalSkillDistribution);
|
||||
skills.add(pickRandom(parameters.requiredSkills, random));
|
||||
Employee employee = new Employee(namePermutations.get(i), skills, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>());
|
||||
employees.add(employee);
|
||||
}
|
||||
employeeSchedule.setEmployees(employees);
|
||||
|
||||
List<Shift> shifts = new LinkedList<>();
|
||||
for (int i = 0; i < parameters.daysInSchedule; i++) {
|
||||
Set<Employee> employeesWithAvailabilitiesOnDay = pickSubset(employees, random,
|
||||
parameters.availabilityCountDistribution);
|
||||
LocalDate date = startDate.plusDays(i);
|
||||
for (Employee employee : employeesWithAvailabilitiesOnDay) {
|
||||
switch (random.nextInt(3)) {
|
||||
case 0 -> employee.getUnavailableDates().add(date);
|
||||
case 1 -> employee.getUndesiredDates().add(date);
|
||||
case 2 -> employee.getDesiredDates().add(date);
|
||||
}
|
||||
}
|
||||
shifts.addAll(generateShiftsForDay(parameters, date, random));
|
||||
}
|
||||
AtomicInteger countShift = new AtomicInteger();
|
||||
shifts.forEach(s -> s.setId(Integer.toString(countShift.getAndIncrement())));
|
||||
employeeSchedule.setShifts(shifts);
|
||||
|
||||
return employeeSchedule;
|
||||
}
|
||||
|
||||
private List<Shift> generateShiftsForDay(DemoDataParameters parameters, LocalDate date, Random random) {
|
||||
List<Shift> shifts = new LinkedList<>();
|
||||
for (String location : parameters.locations) {
|
||||
List<LocalTime> shiftStartTimes = locationToShiftStartTimeListMap.get(location);
|
||||
for (LocalTime shiftStartTime : shiftStartTimes) {
|
||||
LocalDateTime shiftStartDateTime = date.atTime(shiftStartTime);
|
||||
LocalDateTime shiftEndDateTime = shiftStartDateTime.plus(SHIFT_LENGTH);
|
||||
shifts.addAll(generateShiftForTimeslot(parameters, shiftStartDateTime, shiftEndDateTime, location, random));
|
||||
}
|
||||
}
|
||||
return shifts;
|
||||
}
|
||||
|
||||
private List<Shift> generateShiftForTimeslot(DemoDataParameters parameters,
|
||||
LocalDateTime timeslotStart, LocalDateTime timeslotEnd, String location,
|
||||
Random random) {
|
||||
var shiftCount = pickCount(random, parameters.shiftCountDistribution);
|
||||
|
||||
List<Shift> shifts = new LinkedList<>();
|
||||
for (int i = 0; i < shiftCount; i++) {
|
||||
String requiredSkill;
|
||||
if (random.nextBoolean()) {
|
||||
requiredSkill = pickRandom(parameters.requiredSkills, random);
|
||||
} else {
|
||||
requiredSkill = pickRandom(parameters.optionalSkills, random);
|
||||
}
|
||||
shifts.add(new Shift(timeslotStart, timeslotEnd, location, requiredSkill));
|
||||
}
|
||||
return shifts;
|
||||
}
|
||||
|
||||
private <T> T pickRandom(List<T> source, Random random) {
|
||||
return source.get(random.nextInt(source.size()));
|
||||
}
|
||||
|
||||
private int pickCount(Random random, List<CountDistribution> countDistribution) {
|
||||
double probabilitySum = 0;
|
||||
for (var possibility : countDistribution) {
|
||||
probabilitySum += possibility.weight;
|
||||
}
|
||||
var choice = random.nextDouble(probabilitySum);
|
||||
int numOfItems = 0;
|
||||
while (choice >= countDistribution.get(numOfItems).weight) {
|
||||
choice -= countDistribution.get(numOfItems).weight;
|
||||
numOfItems++;
|
||||
}
|
||||
return countDistribution.get(numOfItems).count;
|
||||
}
|
||||
|
||||
private <T> Set<T> pickSubset(List<T> sourceSet, Random random, List<CountDistribution> countDistribution) {
|
||||
var count = pickCount(random, countDistribution);
|
||||
List<T> items = new ArrayList<>(sourceSet);
|
||||
Collections.shuffle(items, random);
|
||||
return new HashSet<>(items.subList(0, count));
|
||||
}
|
||||
|
||||
private List<String> joinAllCombinations(String[]... partArrays) {
|
||||
int size = 1;
|
||||
for (String[] partArray : partArrays) {
|
||||
size *= partArray.length;
|
||||
}
|
||||
List<String> out = new ArrayList<>(size);
|
||||
for (int i = 0; i < size; i++) {
|
||||
StringBuilder item = new StringBuilder();
|
||||
int sizePerIncrement = 1;
|
||||
for (String[] partArray : partArrays) {
|
||||
item.append(' ');
|
||||
item.append(partArray[(i / sizePerIncrement) % partArray.length]);
|
||||
sizePerIncrement *= partArray.length;
|
||||
}
|
||||
item.delete(0, 1);
|
||||
out.add(item.toString());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.acme.employeescheduling.rest;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import org.acme.employeescheduling.domain.EmployeeSchedule;
|
||||
import org.acme.employeescheduling.rest.DemoDataGenerator.DemoData;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
|
||||
@Tag(name = "Demo data", description = "Timefold-provided demo employee schedule data.")
|
||||
@Path("demo-data")
|
||||
public class EmployeeScheduleDemoResource {
|
||||
|
||||
private final DemoDataGenerator dataGenerator;
|
||||
|
||||
@Inject
|
||||
public EmployeeScheduleDemoResource(DemoDataGenerator dataGenerator) {
|
||||
this.dataGenerator = dataGenerator;
|
||||
}
|
||||
|
||||
@APIResponses(value = {
|
||||
@APIResponse(responseCode = "200", description = "List of demo data represented as IDs.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = DemoData.class, type = SchemaType.ARRAY))) })
|
||||
@Operation(summary = "List demo data.")
|
||||
@GET
|
||||
public DemoData[] list() {
|
||||
return DemoData.values();
|
||||
}
|
||||
|
||||
@APIResponses(value = {
|
||||
@APIResponse(responseCode = "200", description = "Unsolved demo schedule.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = EmployeeSchedule.class)))})
|
||||
@Operation(summary = "Find an unsolved demo schedule by ID.")
|
||||
@GET
|
||||
@Path("/{demoDataId}")
|
||||
public Response generate(@PathParam("demoDataId") DemoData demoData) {
|
||||
return Response.ok(dataGenerator.generateDemoData(demoData)).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package org.acme.employeescheduling.rest;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis;
|
||||
import ai.timefold.solver.core.api.score.buildin.hardsoftbigdecimal.HardSoftBigDecimalScore;
|
||||
import ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy;
|
||||
import ai.timefold.solver.core.api.solver.SolutionManager;
|
||||
import ai.timefold.solver.core.api.solver.SolverManager;
|
||||
import ai.timefold.solver.core.api.solver.SolverStatus;
|
||||
|
||||
import org.acme.employeescheduling.domain.EmployeeSchedule;
|
||||
import org.acme.employeescheduling.rest.exception.EmployeeScheduleSolverException;
|
||||
import org.acme.employeescheduling.rest.exception.ErrorInfo;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
||||
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@Tag(name = "Employee Schedules", description = "Employee Schedules service for assigning employees to shifts.")
|
||||
@Path("schedules")
|
||||
public class EmployeeScheduleResource {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(EmployeeScheduleResource.class);
|
||||
|
||||
SolverManager<EmployeeSchedule, String> solverManager;
|
||||
SolutionManager<EmployeeSchedule, HardSoftBigDecimalScore> solutionManager;
|
||||
|
||||
// TODO: Without any "time to live", the map may eventually grow out of memory.
|
||||
private final ConcurrentMap<String, Job> jobIdToJob = new ConcurrentHashMap<>();
|
||||
|
||||
@Inject
|
||||
public EmployeeScheduleResource(SolverManager<EmployeeSchedule, String> solverManager,
|
||||
SolutionManager<EmployeeSchedule, HardSoftBigDecimalScore> solutionManager) {
|
||||
this.solverManager = solverManager;
|
||||
this.solutionManager = solutionManager;
|
||||
}
|
||||
|
||||
@Operation(summary = "List the job IDs of all submitted schedules.")
|
||||
@APIResponses(value = {
|
||||
@APIResponse(responseCode = "200", description = "List of all job IDs.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(type = SchemaType.ARRAY, implementation = String.class))) })
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public Collection<String> list() {
|
||||
return jobIdToJob.keySet();
|
||||
}
|
||||
|
||||
@Operation(summary = "Submit a schedule to start solving as soon as CPU resources are available.")
|
||||
@APIResponses(value = {
|
||||
@APIResponse(responseCode = "202",
|
||||
description = "The job ID. Use that ID to get the solution with the other methods.",
|
||||
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(implementation = String.class))) })
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
public String solve(EmployeeSchedule problem) {
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
jobIdToJob.put(jobId, Job.ofSchedule(problem));
|
||||
solverManager.solveBuilder()
|
||||
.withProblemId(jobId)
|
||||
.withProblemFinder(jobId_ -> jobIdToJob.get(jobId).schedule)
|
||||
.withBestSolutionConsumer(solution -> jobIdToJob.put(jobId, Job.ofSchedule(solution)))
|
||||
.withExceptionHandler((jobId_, exception) -> {
|
||||
jobIdToJob.put(jobId, Job.ofException(exception));
|
||||
LOGGER.error("Failed solving jobId ({}).", jobId, exception);
|
||||
})
|
||||
.run();
|
||||
return jobId;
|
||||
}
|
||||
|
||||
@Operation(summary = "Submit a schedule to analyze its score.")
|
||||
@APIResponses(value = {
|
||||
@APIResponse(responseCode = "202",
|
||||
description = "Resulting score analysis, optionally without constraint matches.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = ScoreAnalysis.class))) })
|
||||
@PUT
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("analyze")
|
||||
public ScoreAnalysis<HardSoftBigDecimalScore> analyze(EmployeeSchedule problem,
|
||||
@QueryParam("fetchPolicy") ScoreAnalysisFetchPolicy fetchPolicy) {
|
||||
return fetchPolicy == null ? solutionManager.analyze(problem) : solutionManager.analyze(problem, fetchPolicy);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Get the solution and score for a given job ID. This is the best solution so far, as it might still be running or not even started.")
|
||||
@APIResponses(value = {
|
||||
@APIResponse(responseCode = "200", description = "The best solution of the schedule so far.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = EmployeeSchedule.class))),
|
||||
@APIResponse(responseCode = "404", description = "No schedule found.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = ErrorInfo.class))),
|
||||
@APIResponse(responseCode = "500", description = "Exception during solving a schedule.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = ErrorInfo.class)))
|
||||
})
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("{jobId}")
|
||||
public EmployeeSchedule getEmployeeSchedule(
|
||||
@Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) {
|
||||
EmployeeSchedule schedule = getEmployeeScheduleAndCheckForExceptions(jobId);
|
||||
SolverStatus solverStatus = solverManager.getSolverStatus(jobId);
|
||||
schedule.setSolverStatus(solverStatus);
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private EmployeeSchedule getEmployeeScheduleAndCheckForExceptions(String jobId) {
|
||||
Job job = jobIdToJob.get(jobId);
|
||||
if (job == null) {
|
||||
throw new EmployeeScheduleSolverException(jobId, Response.Status.NOT_FOUND, "No schedule found.");
|
||||
}
|
||||
if (job.exception != null) {
|
||||
throw new EmployeeScheduleSolverException(jobId, job.exception);
|
||||
}
|
||||
return job.schedule;
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Terminate solving for a given job ID. Returns the best solution of the schedule so far, as it might still be running or not even started.")
|
||||
@APIResponses(value = {
|
||||
@APIResponse(responseCode = "200", description = "The best solution of the schedule so far.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = EmployeeSchedule.class))),
|
||||
@APIResponse(responseCode = "404", description = "No schedule found.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = ErrorInfo.class))),
|
||||
@APIResponse(responseCode = "500", description = "Exception during solving a schedule.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = ErrorInfo.class)))
|
||||
})
|
||||
@DELETE
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("{jobId}")
|
||||
public EmployeeSchedule terminateSolving(
|
||||
@Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) {
|
||||
// TODO: Replace with .terminateEarlyAndWait(... [, timeout]); see https://github.com/TimefoldAI/timefold-solver/issues/77
|
||||
solverManager.terminateEarly(jobId);
|
||||
return getEmployeeSchedule(jobId);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Get the schedule status and score for a given job ID.")
|
||||
@APIResponses(value = {
|
||||
@APIResponse(responseCode = "200", description = "The schedule status and the best score so far.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = EmployeeSchedule.class))),
|
||||
@APIResponse(responseCode = "404", description = "No schedule found.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = ErrorInfo.class))),
|
||||
@APIResponse(responseCode = "500", description = "Exception during solving a schedule.",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||
schema = @Schema(implementation = ErrorInfo.class)))
|
||||
})
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path("{jobId}/status")
|
||||
public EmployeeSchedule getStatus(
|
||||
@Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) {
|
||||
EmployeeSchedule schedule = getEmployeeScheduleAndCheckForExceptions(jobId);
|
||||
SolverStatus solverStatus = solverManager.getSolverStatus(jobId);
|
||||
return new EmployeeSchedule(schedule.getScore(), solverStatus);
|
||||
}
|
||||
|
||||
private record Job(EmployeeSchedule schedule, Throwable exception) {
|
||||
|
||||
static Job ofSchedule(EmployeeSchedule schedule) {
|
||||
return new Job(schedule, null);
|
||||
}
|
||||
|
||||
static Job ofException(Throwable error) {
|
||||
return new Job(null, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.acme.employeescheduling.rest.exception;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
public class EmployeeScheduleSolverException extends RuntimeException {
|
||||
|
||||
private final String jobId;
|
||||
|
||||
private final Response.Status status;
|
||||
|
||||
public EmployeeScheduleSolverException(String jobId, Response.Status status, String message) {
|
||||
super(message);
|
||||
this.jobId = jobId;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public EmployeeScheduleSolverException(String jobId, Throwable cause) {
|
||||
super(cause.getMessage(), cause);
|
||||
this.jobId = jobId;
|
||||
this.status = Response.Status.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
public String getJobId() {
|
||||
return jobId;
|
||||
}
|
||||
|
||||
public Response.Status getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.acme.employeescheduling.rest.exception;
|
||||
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.ext.ExceptionMapper;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
|
||||
@Provider
|
||||
public class EmployeeScheduleSolverExceptionMapper implements ExceptionMapper<EmployeeScheduleSolverException> {
|
||||
|
||||
@Override
|
||||
public Response toResponse(EmployeeScheduleSolverException exception) {
|
||||
return Response
|
||||
.status(exception.getStatus())
|
||||
.type(MediaType.APPLICATION_JSON)
|
||||
.entity(new ErrorInfo(exception.getJobId(), exception.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package org.acme.employeescheduling.rest.exception;
|
||||
|
||||
public record ErrorInfo(String jobId, String message) {
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.acme.employeescheduling.solver;
|
||||
|
||||
import static ai.timefold.solver.core.api.score.stream.Joiners.equal;
|
||||
import static ai.timefold.solver.core.api.score.stream.Joiners.lessThanOrEqual;
|
||||
import static ai.timefold.solver.core.api.score.stream.Joiners.overlapping;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.function.Function;
|
||||
|
||||
import ai.timefold.solver.core.api.score.buildin.hardsoftbigdecimal.HardSoftBigDecimalScore;
|
||||
import ai.timefold.solver.core.api.score.stream.Constraint;
|
||||
import ai.timefold.solver.core.api.score.stream.ConstraintCollectors;
|
||||
import ai.timefold.solver.core.api.score.stream.ConstraintFactory;
|
||||
import ai.timefold.solver.core.api.score.stream.ConstraintProvider;
|
||||
import ai.timefold.solver.core.api.score.stream.common.LoadBalance;
|
||||
|
||||
import org.acme.employeescheduling.domain.Employee;
|
||||
import org.acme.employeescheduling.domain.Shift;
|
||||
|
||||
public class EmployeeSchedulingConstraintProvider implements ConstraintProvider {
|
||||
|
||||
private static int getMinuteOverlap(Shift shift1, Shift shift2) {
|
||||
// The overlap of two timeslot occurs in the range common to both timeslots.
|
||||
// Both timeslots are active after the higher of their two start times,
|
||||
// and before the lower of their two end times.
|
||||
LocalDateTime shift1Start = shift1.getStart();
|
||||
LocalDateTime shift1End = shift1.getEnd();
|
||||
LocalDateTime shift2Start = shift2.getStart();
|
||||
LocalDateTime shift2End = shift2.getEnd();
|
||||
return (int) Duration.between((shift1Start.isAfter(shift2Start)) ? shift1Start : shift2Start,
|
||||
(shift1End.isBefore(shift2End)) ? shift1End : shift2End).toMinutes();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
|
||||
return new Constraint[] {
|
||||
// Hard constraints
|
||||
requiredSkill(constraintFactory),
|
||||
noOverlappingShifts(constraintFactory),
|
||||
atLeast10HoursBetweenTwoShifts(constraintFactory),
|
||||
oneShiftPerDay(constraintFactory),
|
||||
unavailableEmployee(constraintFactory),
|
||||
// Soft constraints
|
||||
undesiredDayForEmployee(constraintFactory),
|
||||
desiredDayForEmployee(constraintFactory),
|
||||
balanceEmployeeShiftAssignments(constraintFactory)
|
||||
};
|
||||
}
|
||||
|
||||
Constraint requiredSkill(ConstraintFactory constraintFactory) {
|
||||
return constraintFactory.forEach(Shift.class)
|
||||
.filter(shift -> !shift.getEmployee().getSkills().contains(shift.getRequiredSkill()))
|
||||
.penalize(HardSoftBigDecimalScore.ONE_HARD)
|
||||
.asConstraint("Missing required skill");
|
||||
}
|
||||
|
||||
Constraint noOverlappingShifts(ConstraintFactory constraintFactory) {
|
||||
return constraintFactory.forEachUniquePair(Shift.class, equal(Shift::getEmployee),
|
||||
overlapping(Shift::getStart, Shift::getEnd))
|
||||
.penalize(HardSoftBigDecimalScore.ONE_HARD,
|
||||
EmployeeSchedulingConstraintProvider::getMinuteOverlap)
|
||||
.asConstraint("Overlapping shift");
|
||||
}
|
||||
|
||||
Constraint atLeast10HoursBetweenTwoShifts(ConstraintFactory constraintFactory) {
|
||||
return constraintFactory.forEach(Shift.class)
|
||||
.join(Shift.class, equal(Shift::getEmployee), lessThanOrEqual(Shift::getEnd, Shift::getStart))
|
||||
.filter((firstShift,
|
||||
secondShift) -> Duration.between(firstShift.getEnd(), secondShift.getStart()).toHours() < 10)
|
||||
.penalize(HardSoftBigDecimalScore.ONE_HARD,
|
||||
(firstShift, secondShift) -> {
|
||||
int breakLength = (int) Duration.between(firstShift.getEnd(), secondShift.getStart()).toMinutes();
|
||||
return (10 * 60) - breakLength;
|
||||
})
|
||||
.asConstraint("At least 10 hours between 2 shifts");
|
||||
}
|
||||
|
||||
Constraint oneShiftPerDay(ConstraintFactory constraintFactory) {
|
||||
return constraintFactory.forEachUniquePair(Shift.class, equal(Shift::getEmployee),
|
||||
equal(shift -> shift.getStart().toLocalDate()))
|
||||
.penalize(HardSoftBigDecimalScore.ONE_HARD)
|
||||
.asConstraint("Max one shift per day");
|
||||
}
|
||||
|
||||
Constraint unavailableEmployee(ConstraintFactory constraintFactory) {
|
||||
return constraintFactory.forEach(Shift.class)
|
||||
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
|
||||
.flattenLast(Employee::getUnavailableDates)
|
||||
.filter(Shift::isOverlappingWithDate)
|
||||
.penalize(HardSoftBigDecimalScore.ONE_HARD, Shift::getOverlappingDurationInMinutes)
|
||||
.asConstraint("Unavailable employee");
|
||||
}
|
||||
|
||||
Constraint undesiredDayForEmployee(ConstraintFactory constraintFactory) {
|
||||
return constraintFactory.forEach(Shift.class)
|
||||
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
|
||||
.flattenLast(Employee::getUndesiredDates)
|
||||
.filter(Shift::isOverlappingWithDate)
|
||||
.penalize(HardSoftBigDecimalScore.ONE_SOFT, Shift::getOverlappingDurationInMinutes)
|
||||
.asConstraint("Undesired day for employee");
|
||||
}
|
||||
|
||||
Constraint desiredDayForEmployee(ConstraintFactory constraintFactory) {
|
||||
return constraintFactory.forEach(Shift.class)
|
||||
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
|
||||
.flattenLast(Employee::getDesiredDates)
|
||||
.filter(Shift::isOverlappingWithDate)
|
||||
.reward(HardSoftBigDecimalScore.ONE_SOFT, Shift::getOverlappingDurationInMinutes)
|
||||
.asConstraint("Desired day for employee");
|
||||
}
|
||||
|
||||
Constraint balanceEmployeeShiftAssignments(ConstraintFactory constraintFactory) {
|
||||
return constraintFactory.forEach(Shift.class)
|
||||
.groupBy(Shift::getEmployee, ConstraintCollectors.count())
|
||||
.complement(Employee.class, e -> 0) // Include all employees which are not assigned to any shift.c
|
||||
.groupBy(ConstraintCollectors.loadBalance((employee, shiftCount) -> employee,
|
||||
(employee, shiftCount) -> shiftCount))
|
||||
.penalizeBigDecimal(HardSoftBigDecimalScore.ONE_SOFT, LoadBalance::unfairness)
|
||||
.asConstraint("Balance employee shift assignments");
|
||||
}
|
||||
|
||||
}
|
||||
496
src/main/resources/META-INF/resources/app.js
Normal file
496
src/main/resources/META-INF/resources/app.js
Normal file
@@ -0,0 +1,496 @@
|
||||
let autoRefreshIntervalId = null;
|
||||
const zoomMin = 2 * 1000 * 60 * 60 * 24 // 2 day in milliseconds
|
||||
const zoomMax = 4 * 7 * 1000 * 60 * 60 * 24 // 4 weeks in milliseconds
|
||||
|
||||
const UNAVAILABLE_COLOR = '#ef2929' // Tango Scarlet Red
|
||||
const UNDESIRED_COLOR = '#f57900' // Tango Orange
|
||||
const DESIRED_COLOR = '#73d216' // Tango Chameleon
|
||||
|
||||
let demoDataId = null;
|
||||
let scheduleId = null;
|
||||
let loadedSchedule = null;
|
||||
|
||||
const byEmployeePanel = document.getElementById("byEmployeePanel");
|
||||
const byEmployeeTimelineOptions = {
|
||||
timeAxis: {scale: "hour", step: 6},
|
||||
orientation: {axis: "top"},
|
||||
stack: false,
|
||||
xss: {disabled: true}, // Items are XSS safe through JQuery
|
||||
zoomMin: zoomMin,
|
||||
zoomMax: zoomMax,
|
||||
};
|
||||
let byEmployeeGroupDataSet = new vis.DataSet();
|
||||
let byEmployeeItemDataSet = new vis.DataSet();
|
||||
let byEmployeeTimeline = new vis.Timeline(byEmployeePanel, byEmployeeItemDataSet, byEmployeeGroupDataSet, byEmployeeTimelineOptions);
|
||||
|
||||
const byLocationPanel = document.getElementById("byLocationPanel");
|
||||
const byLocationTimelineOptions = {
|
||||
timeAxis: {scale: "hour", step: 6},
|
||||
orientation: {axis: "top"},
|
||||
xss: {disabled: true}, // Items are XSS safe through JQuery
|
||||
zoomMin: zoomMin,
|
||||
zoomMax: zoomMax,
|
||||
};
|
||||
let byLocationGroupDataSet = new vis.DataSet();
|
||||
let byLocationItemDataSet = new vis.DataSet();
|
||||
let byLocationTimeline = new vis.Timeline(byLocationPanel, byLocationItemDataSet, byLocationGroupDataSet, byLocationTimelineOptions);
|
||||
|
||||
let windowStart = JSJoda.LocalDate.now().toString();
|
||||
let windowEnd = JSJoda.LocalDate.parse(windowStart).plusDays(7).toString();
|
||||
|
||||
$(document).ready(function () {
|
||||
replaceQuickstartTimefoldAutoHeaderFooter();
|
||||
|
||||
$("#solveButton").click(function () {
|
||||
solve();
|
||||
});
|
||||
$("#stopSolvingButton").click(function () {
|
||||
stopSolving();
|
||||
});
|
||||
$("#analyzeButton").click(function () {
|
||||
analyze();
|
||||
});
|
||||
// HACK to allow vis-timeline to work within Bootstrap tabs
|
||||
$("#byEmployeeTab").on('shown.bs.tab', function (event) {
|
||||
byEmployeeTimeline.redraw();
|
||||
})
|
||||
$("#byLocationTab").on('shown.bs.tab', function (event) {
|
||||
byLocationTimeline.redraw();
|
||||
})
|
||||
|
||||
setupAjax();
|
||||
fetchDemoData();
|
||||
});
|
||||
|
||||
function setupAjax() {
|
||||
$.ajaxSetup({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job
|
||||
}
|
||||
});
|
||||
// Extend jQuery to support $.put() and $.delete()
|
||||
jQuery.each(["put", "delete"], function (i, method) {
|
||||
jQuery[method] = function (url, data, callback, type) {
|
||||
if (jQuery.isFunction(data)) {
|
||||
type = type || callback;
|
||||
callback = data;
|
||||
data = undefined;
|
||||
}
|
||||
return jQuery.ajax({
|
||||
url: url,
|
||||
type: method,
|
||||
dataType: type,
|
||||
data: data,
|
||||
success: callback
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function fetchDemoData() {
|
||||
$.get("/demo-data", function (data) {
|
||||
data.forEach(item => {
|
||||
$("#testDataButton").append($('<a id="' + item + 'TestData" class="dropdown-item" href="#">' + item + '</a>'));
|
||||
$("#" + item + "TestData").click(function () {
|
||||
switchDataDropDownItemActive(item);
|
||||
scheduleId = null;
|
||||
demoDataId = item;
|
||||
|
||||
refreshSchedule();
|
||||
});
|
||||
});
|
||||
demoDataId = data[0];
|
||||
switchDataDropDownItemActive(demoDataId);
|
||||
refreshSchedule();
|
||||
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||
// disable this page as there is no data
|
||||
let $demo = $("#demo");
|
||||
$demo.empty();
|
||||
$demo.html("<h1><p align=\"center\">No test data available</p></h1>")
|
||||
});
|
||||
}
|
||||
|
||||
function switchDataDropDownItemActive(newItem) {
|
||||
activeCssClass = "active";
|
||||
$("#testDataButton > a." + activeCssClass).removeClass(activeCssClass);
|
||||
$("#" + newItem + "TestData").addClass(activeCssClass);
|
||||
}
|
||||
|
||||
function getShiftColor(shift, employee) {
|
||||
const shiftStart = JSJoda.LocalDateTime.parse(shift.start);
|
||||
const shiftStartDateString = shiftStart.toLocalDate().toString();
|
||||
const shiftEnd = JSJoda.LocalDateTime.parse(shift.end);
|
||||
const shiftEndDateString = shiftEnd.toLocalDate().toString();
|
||||
if (employee.unavailableDates.includes(shiftStartDateString) ||
|
||||
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||
employee.unavailableDates.includes(shiftEndDateString))) {
|
||||
return UNAVAILABLE_COLOR
|
||||
} else if (employee.undesiredDates.includes(shiftStartDateString) ||
|
||||
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||
employee.undesiredDates.includes(shiftEndDateString))) {
|
||||
return UNDESIRED_COLOR
|
||||
} else if (employee.desiredDates.includes(shiftStartDateString) ||
|
||||
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||
employee.desiredDates.includes(shiftEndDateString))) {
|
||||
return DESIRED_COLOR
|
||||
} else {
|
||||
return " #729fcf"; // Tango Sky Blue
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSchedule() {
|
||||
let path = "/schedules/" + scheduleId;
|
||||
if (scheduleId === null) {
|
||||
if (demoDataId === null) {
|
||||
alert("Please select a test data set.");
|
||||
return;
|
||||
}
|
||||
|
||||
path = "/demo-data/" + demoDataId;
|
||||
}
|
||||
$.getJSON(path, function (schedule) {
|
||||
loadedSchedule = schedule;
|
||||
renderSchedule(schedule);
|
||||
})
|
||||
.fail(function (xhr, ajaxOptions, thrownError) {
|
||||
showError("Getting the schedule has failed.", xhr);
|
||||
refreshSolvingButtons(false);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSchedule(schedule) {
|
||||
refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING");
|
||||
$("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score));
|
||||
|
||||
const unassignedShifts = $("#unassignedShifts");
|
||||
const groups = [];
|
||||
|
||||
// Show only first 7 days of draft
|
||||
const scheduleStart = schedule.shifts.map(shift => JSJoda.LocalDateTime.parse(shift.start).toLocalDate()).sort()[0].toString();
|
||||
const scheduleEnd = JSJoda.LocalDate.parse(scheduleStart).plusDays(7).toString();
|
||||
|
||||
windowStart = scheduleStart;
|
||||
windowEnd = scheduleEnd;
|
||||
|
||||
unassignedShifts.children().remove();
|
||||
let unassignedShiftsCount = 0;
|
||||
byEmployeeGroupDataSet.clear();
|
||||
byLocationGroupDataSet.clear();
|
||||
|
||||
byEmployeeItemDataSet.clear();
|
||||
byLocationItemDataSet.clear();
|
||||
|
||||
|
||||
schedule.employees.forEach((employee, index) => {
|
||||
const employeeGroupElement = $('<div class="card-body p-2"/>')
|
||||
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||
.append(employee.name))
|
||||
.append($('<div/>')
|
||||
.append($(employee.skills.map(skill => `<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${skill}</span>`).join(''))));
|
||||
byEmployeeGroupDataSet.add({id: employee.name, content: employeeGroupElement.html()});
|
||||
|
||||
employee.unavailableDates.forEach((rawDate, dateIndex) => {
|
||||
const date = JSJoda.LocalDate.parse(rawDate)
|
||||
const start = date.atStartOfDay().toString();
|
||||
const end = date.plusDays(1).atStartOfDay().toString();
|
||||
const byEmployeeShiftElement = $(`<div/>`)
|
||||
.append($(`<h5 class="card-title mb-1"/>`).text("Unavailable"));
|
||||
byEmployeeItemDataSet.add({
|
||||
id: "employee-" + index + "-unavailability-" + dateIndex, group: employee.name,
|
||||
content: byEmployeeShiftElement.html(),
|
||||
start: start, end: end,
|
||||
type: "background",
|
||||
style: "opacity: 0.5; background-color: " + UNAVAILABLE_COLOR,
|
||||
});
|
||||
});
|
||||
employee.undesiredDates.forEach((rawDate, dateIndex) => {
|
||||
const date = JSJoda.LocalDate.parse(rawDate)
|
||||
const start = date.atStartOfDay().toString();
|
||||
const end = date.plusDays(1).atStartOfDay().toString();
|
||||
const byEmployeeShiftElement = $(`<div/>`)
|
||||
.append($(`<h5 class="card-title mb-1"/>`).text("Undesired"));
|
||||
byEmployeeItemDataSet.add({
|
||||
id: "employee-" + index + "-undesired-" + dateIndex, group: employee.name,
|
||||
content: byEmployeeShiftElement.html(),
|
||||
start: start, end: end,
|
||||
type: "background",
|
||||
style: "opacity: 0.5; background-color: " + UNDESIRED_COLOR,
|
||||
});
|
||||
});
|
||||
employee.desiredDates.forEach((rawDate, dateIndex) => {
|
||||
const date = JSJoda.LocalDate.parse(rawDate)
|
||||
const start = date.atStartOfDay().toString();
|
||||
const end = date.plusDays(1).atStartOfDay().toString();
|
||||
const byEmployeeShiftElement = $(`<div/>`)
|
||||
.append($(`<h5 class="card-title mb-1"/>`).text("Desired"));
|
||||
byEmployeeItemDataSet.add({
|
||||
id: "employee-" + index + "-desired-" + dateIndex, group: employee.name,
|
||||
content: byEmployeeShiftElement.html(),
|
||||
start: start, end: end,
|
||||
type: "background",
|
||||
style: "opacity: 0.5; background-color: " + DESIRED_COLOR,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
schedule.shifts.forEach((shift, index) => {
|
||||
if (groups.indexOf(shift.location) === -1) {
|
||||
groups.push(shift.location);
|
||||
byLocationGroupDataSet.add({
|
||||
id: shift.location,
|
||||
content: shift.location,
|
||||
});
|
||||
}
|
||||
|
||||
if (shift.employee == null) {
|
||||
unassignedShiftsCount++;
|
||||
|
||||
const byLocationShiftElement = $('<div class="card-body p-2"/>')
|
||||
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||
.append("Unassigned"))
|
||||
.append($('<div/>')
|
||||
.append($(`<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${shift.requiredSkill}</span>`)));
|
||||
|
||||
byLocationItemDataSet.add({
|
||||
id: 'shift-' + index, group: shift.location,
|
||||
content: byLocationShiftElement.html(),
|
||||
start: shift.start, end: shift.end,
|
||||
style: "background-color: #EF292999"
|
||||
});
|
||||
} else {
|
||||
const skillColor = (shift.employee.skills.indexOf(shift.requiredSkill) === -1 ? '#ef2929' : '#8ae234');
|
||||
const byEmployeeShiftElement = $('<div class="card-body p-2"/>')
|
||||
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||
.append(shift.location))
|
||||
.append($('<div/>')
|
||||
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
|
||||
const byLocationShiftElement = $('<div class="card-body p-2"/>')
|
||||
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||
.append(shift.employee.name))
|
||||
.append($('<div/>')
|
||||
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
|
||||
|
||||
const shiftColor = getShiftColor(shift, shift.employee);
|
||||
byEmployeeItemDataSet.add({
|
||||
id: 'shift-' + index, group: shift.employee.name,
|
||||
content: byEmployeeShiftElement.html(),
|
||||
start: shift.start, end: shift.end,
|
||||
style: "background-color: " + shiftColor
|
||||
});
|
||||
byLocationItemDataSet.add({
|
||||
id: 'shift-' + index, group: shift.location,
|
||||
content: byLocationShiftElement.html(),
|
||||
start: shift.start, end: shift.end,
|
||||
style: "background-color: " + shiftColor
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (unassignedShiftsCount === 0) {
|
||||
unassignedShifts.append($(`<p/>`).text(`There are no unassigned shifts.`));
|
||||
} else {
|
||||
unassignedShifts.append($(`<p/>`).text(`There are ${unassignedShiftsCount} unassigned shifts.`));
|
||||
}
|
||||
byEmployeeTimeline.setWindow(scheduleStart, scheduleEnd);
|
||||
byLocationTimeline.setWindow(scheduleStart, scheduleEnd);
|
||||
}
|
||||
|
||||
function solve() {
|
||||
$.post("/schedules", JSON.stringify(loadedSchedule), function (data) {
|
||||
scheduleId = data;
|
||||
refreshSolvingButtons(true);
|
||||
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||
showError("Start solving failed.", xhr);
|
||||
refreshSolvingButtons(false);
|
||||
},
|
||||
"text");
|
||||
}
|
||||
|
||||
function analyze() {
|
||||
new bootstrap.Modal("#scoreAnalysisModal").show()
|
||||
const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
|
||||
scoreAnalysisModalContent.children().remove();
|
||||
if (loadedSchedule.score == null) {
|
||||
scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button.");
|
||||
} else {
|
||||
$('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
|
||||
$.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) {
|
||||
let constraints = scoreAnalysis.constraints;
|
||||
constraints.sort((a, b) => {
|
||||
let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
|
||||
if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
|
||||
if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
|
||||
if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
|
||||
return -1;
|
||||
} else {
|
||||
if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
|
||||
if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
|
||||
if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
|
||||
return -1;
|
||||
} else {
|
||||
if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
|
||||
if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
|
||||
|
||||
return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
|
||||
}
|
||||
}
|
||||
});
|
||||
constraints.map((e) => {
|
||||
let components = getScoreComponents(e.weight);
|
||||
e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
|
||||
e.weight = components[e.type];
|
||||
let scores = getScoreComponents(e.score);
|
||||
e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
|
||||
});
|
||||
scoreAnalysis.constraints = constraints;
|
||||
|
||||
scoreAnalysisModalContent.children().remove();
|
||||
scoreAnalysisModalContent.text("");
|
||||
|
||||
const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
|
||||
const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
|
||||
.append($(`<th></th>`))
|
||||
.append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
|
||||
.append($(`<th>Type</th>`))
|
||||
.append($(`<th># Matches</th>`))
|
||||
.append($(`<th>Weight</th>`))
|
||||
.append($(`<th>Score</th>`))
|
||||
.append($(`<th></th>`)));
|
||||
analysisTable.append(analysisTHead);
|
||||
const analysisTBody = $(`<tbody/>`)
|
||||
$.each(scoreAnalysis.constraints, (index, constraintAnalysis) => {
|
||||
let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
|
||||
if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
|
||||
|
||||
let row = $(`<tr/>`);
|
||||
row.append($(`<td/>`).html(icon))
|
||||
.append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
|
||||
.append($(`<td/>`).text(constraintAnalysis.type))
|
||||
.append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
|
||||
.append($(`<td/>`).text(constraintAnalysis.weight))
|
||||
.append($(`<td/>`).text(constraintAnalysis.implicitScore));
|
||||
analysisTBody.append(row);
|
||||
row.append($(`<td/>`));
|
||||
});
|
||||
analysisTable.append(analysisTBody);
|
||||
scoreAnalysisModalContent.append(analysisTable);
|
||||
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||
showError("Analyze failed.", xhr);
|
||||
}, "text");
|
||||
}
|
||||
}
|
||||
|
||||
function getScoreComponents(score) {
|
||||
let components = {hard: 0, medium: 0, soft: 0};
|
||||
|
||||
$.each([...score.matchAll(/(-?\d*(\.\d+)?)(hard|medium|soft)/g)], (i, parts) => {
|
||||
components[parts[3]] = parseFloat(parts[1], 10);
|
||||
});
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
function refreshSolvingButtons(solving) {
|
||||
if (solving) {
|
||||
$("#solveButton").hide();
|
||||
$("#stopSolvingButton").show();
|
||||
if (autoRefreshIntervalId == null) {
|
||||
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
||||
}
|
||||
} else {
|
||||
$("#solveButton").show();
|
||||
$("#stopSolvingButton").hide();
|
||||
if (autoRefreshIntervalId != null) {
|
||||
clearInterval(autoRefreshIntervalId);
|
||||
autoRefreshIntervalId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSolvingButtons(solving) {
|
||||
if (solving) {
|
||||
$("#solveButton").hide();
|
||||
$("#stopSolvingButton").show();
|
||||
if (autoRefreshIntervalId == null) {
|
||||
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
||||
}
|
||||
} else {
|
||||
$("#solveButton").show();
|
||||
$("#stopSolvingButton").hide();
|
||||
if (autoRefreshIntervalId != null) {
|
||||
clearInterval(autoRefreshIntervalId);
|
||||
autoRefreshIntervalId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopSolving() {
|
||||
$.delete(`/schedules/${scheduleId}`, function () {
|
||||
refreshSolvingButtons(false);
|
||||
refreshSchedule();
|
||||
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||
showError("Stop solving failed.", xhr);
|
||||
});
|
||||
}
|
||||
|
||||
function replaceQuickstartTimefoldAutoHeaderFooter() {
|
||||
const timefoldHeader = $("header#timefold-auto-header");
|
||||
if (timefoldHeader != null) {
|
||||
timefoldHeader.addClass("bg-black")
|
||||
timefoldHeader.append(
|
||||
$(`<div class="container-fluid">
|
||||
<nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
|
||||
<a class="navbar-brand" href="https://timefold.ai">
|
||||
<img src="/webjars/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item active" id="navUIItem">
|
||||
<button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button">Demo UI</button>
|
||||
</li>
|
||||
<li class="nav-item" id="navRestItem">
|
||||
<button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button">Guide</button>
|
||||
</li>
|
||||
<li class="nav-item" id="navOpenApiItem">
|
||||
<button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button">REST API</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
Data
|
||||
</button>
|
||||
<div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>`));
|
||||
}
|
||||
|
||||
const timefoldFooter = $("footer#timefold-auto-footer");
|
||||
if (timefoldFooter != null) {
|
||||
timefoldFooter.append(
|
||||
$(`<footer class="bg-black text-white-50">
|
||||
<div class="container">
|
||||
<div class="hstack gap-3 p-4">
|
||||
<div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div>
|
||||
<div class="vr"></div>
|
||||
<div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
|
||||
<div class="vr"></div>
|
||||
<div><a class="text-white" href="https://github.com/TimefoldAI/timefold-quickstarts">Code</a></div>
|
||||
<div class="vr"></div>
|
||||
<div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>`));
|
||||
}
|
||||
}
|
||||
161
src/main/resources/META-INF/resources/index.html
Normal file
161
src/main/resources/META-INF/resources/index.html
Normal file
@@ -0,0 +1,161 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
<title>Employee scheduling - Timefold Solver on Quarkus</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
|
||||
integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.min.css"/>
|
||||
<link rel="stylesheet" href="/webjars/font-awesome/css/all.css"/>
|
||||
<link rel="stylesheet" href="/webjars/timefold/css/timefold-webui.css"/>
|
||||
<style>
|
||||
.vis-time-axis .vis-grid.vis-saturday,
|
||||
.vis-time-axis .vis-grid.vis-sunday {
|
||||
background: #D3D7CFFF;
|
||||
}
|
||||
</style>
|
||||
<link rel="icon" href="/webjars/timefold/img/timefold-favicon.svg" type="image/svg+xml">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header id="timefold-auto-header">
|
||||
<!-- Filled in by app.js -->
|
||||
</header>
|
||||
<div class="tab-content">
|
||||
<div id="demo" class="tab-pane fade show active container-fluid">
|
||||
<div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite"
|
||||
aria-atomic="true">
|
||||
<div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
|
||||
</div>
|
||||
<h1>Employee scheduling solver</h1>
|
||||
<p>Generate the optimal schedule for your employees.</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<button id="solveButton" type="button" class="btn btn-success">
|
||||
<span class="fas fa-play"></span> Solve
|
||||
</button>
|
||||
<button id="stopSolvingButton" type="button" class="btn btn-danger">
|
||||
<span class="fas fa-stop"></span> Stop solving
|
||||
</button>
|
||||
<span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
|
||||
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
|
||||
<button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
|
||||
<span class="fas fa-question"></span>
|
||||
</button>
|
||||
|
||||
<div class="float-end">
|
||||
<ul class="nav nav-pills" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="byLocationTab" data-bs-toggle="tab"
|
||||
data-bs-target="#byLocationPanel" type="button" role="tab"
|
||||
aria-controls="byLocationPanel" aria-selected="true">By location
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="byEmployeeTab" data-bs-toggle="tab"
|
||||
data-bs-target="#byEmployeePanel" type="button" role="tab"
|
||||
aria-controls="byEmployeePanel" aria-selected="false">By employee
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 tab-content">
|
||||
<div class="tab-pane fade show active" id="byLocationPanel" role="tabpanel"
|
||||
aria-labelledby="byLocationTab">
|
||||
<div id="locationVisualization"></div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="byEmployeePanel" role="tabpanel" aria-labelledby="byEmployeeTab">
|
||||
<div id="employeeVisualization"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rest" class="tab-pane fade container-fluid">
|
||||
<h1>REST API Guide</h1>
|
||||
|
||||
<h2>Employee Scheduling solver integration via cURL</h2>
|
||||
|
||||
<h3>1. Download demo data</h3>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl1')">Copy</button>
|
||||
<code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data/SMALL -o sample.json</code>
|
||||
</pre>
|
||||
|
||||
<h3>2. Post the sample data for solving</h3>
|
||||
<p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl2')">Copy</button>
|
||||
<code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json</code>
|
||||
</pre>
|
||||
|
||||
<h3>3. Get the current status and score</h3>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl3')">Copy</button>
|
||||
<code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status</code>
|
||||
</pre>
|
||||
|
||||
<h3>4. Get the complete solution</h3>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl4')">Copy</button>
|
||||
<code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}</code>
|
||||
</pre>
|
||||
|
||||
<h3>5. Fetch the analysis of the solution</h3>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl5')">Copy</button>
|
||||
<code id="curl5">curl -X PUT -H 'Content-Type:application/json' http://localhost:8080/schedules/analyze -d@solution.json</code>
|
||||
</pre>
|
||||
|
||||
<h3>5. Terminate solving early</h3>
|
||||
<pre>
|
||||
<button class="btn btn-outline-dark btn-sm float-end"
|
||||
onclick="copyTextToClipboard('curl5')">Copy</button>
|
||||
<code id="curl6">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{id}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div id="openapi" class="tab-pane fade container-fluid">
|
||||
<h1>REST API Reference</h1>
|
||||
<div class="ratio ratio-1x1">
|
||||
<!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
|
||||
<iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer id="timefold-auto-footer"></footer>
|
||||
<div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1"
|
||||
aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span
|
||||
id="scoreAnalysisScoreLabel"></span></h1>
|
||||
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="scoreAnalysisModalContent">
|
||||
<!-- Filled in by app.js -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/webjars/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/webjars/jquery/jquery.min.js"></script>
|
||||
<script src="/webjars/js-joda/dist/js-joda.min.js"></script>
|
||||
<script src="/webjars/timefold/js/timefold-webui.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
|
||||
integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
src/main/resources/application.properties
Normal file
43
src/main/resources/application.properties
Normal file
@@ -0,0 +1,43 @@
|
||||
########################
|
||||
# Timefold Solver properties
|
||||
########################
|
||||
|
||||
# The solver runs for 30 seconds. To run for 5 minutes use "5m" and for 2 hours use "2h".
|
||||
quarkus.timefold.solver.termination.spent-limit=30s
|
||||
|
||||
# To change how many solvers to run in parallel
|
||||
# timefold.solver-manager.parallel-solver-count=4
|
||||
|
||||
# Temporary comment this out to detect bugs in your code (lowers performance)
|
||||
# quarkus.timefold.solver.environment-mode=FULL_ASSERT
|
||||
|
||||
# Temporary comment this out to return a feasible solution as soon as possible
|
||||
# quarkus.timefold.solver.termination.best-score-limit=0hard/*soft
|
||||
|
||||
# To see what Timefold is doing, turn on DEBUG or TRACE logging.
|
||||
quarkus.log.category."ai.timefold.solver".level=INFO
|
||||
%test.quarkus.log.category."ai.timefold.solver".level=INFO
|
||||
%prod.quarkus.log.category."ai.timefold.solver".level=INFO
|
||||
|
||||
# XML file for power tweaking, defaults to solverConfig.xml (directly under src/main/resources)
|
||||
# quarkus.timefold.solver-config-xml=org/.../maintenanceScheduleSolverConfig.xml
|
||||
|
||||
########################
|
||||
# Timefold Solver Enterprise properties
|
||||
########################
|
||||
|
||||
# To run increase CPU cores usage per solver
|
||||
%enterprise.quarkus.timefold.solver.move-thread-count=AUTO
|
||||
|
||||
########################
|
||||
# Native build properties
|
||||
########################
|
||||
|
||||
# Enable Swagger UI also in the native mode
|
||||
quarkus.swagger-ui.always-include=true
|
||||
|
||||
########################
|
||||
# Test overrides
|
||||
########################
|
||||
|
||||
%test.quarkus.timefold.solver.termination.spent-limit=10s
|
||||
Reference in New Issue
Block a user