S9 OR Tools basic scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s

This commit is contained in:
2026-10-30 15:00:45 +01:00
parent f64aa9123d
commit 1b4c7f7f2f
43 changed files with 1042 additions and 118 deletions

View File

@@ -4,6 +4,7 @@ import lombok.*;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import java.util.List;
import java.time.LocalDate;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -12,7 +13,9 @@ import java.util.List;
public class DayPart extends AbstractEntity {
@ToString.Include
private final DayPartType type;
private final LocalDate date;
@ToString.Include
@Setter
private List<DanceGroup> danceGroups;
public void removeDanceGroup(DanceGroup group) {

View File

@@ -3,7 +3,8 @@ package nl.agilecc.agiledancers.model;
import lombok.*;
import java.util.List;
import java.time.LocalDate;
import java.util.Map;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -13,7 +14,8 @@ public class Show extends AbstractEntity {
@Setter
private String name;
@Setter
private LocalDate date;
private Map<DanceGroupType, Integer> appearances;
@ToString.Include
private final List<DayPart> dayParts;
@Setter
private List<DayPart> dayParts;
}

View File

@@ -1,18 +1,5 @@
package nl.agilecc.agiledancers.model.specifications;
import lombok.Getter;
public enum DanceGroupType {
SHOW_CASE(3),
PRE_EDUCATION(1), // TODO check if this is one group mentioned with different docent each time, or three different groups - and thus if they are required 1 day or 3
SELECTION(2),
REGULAR(2),
PRE_SCHOOLER(1);
@Getter
private int dayPartsRequired;
DanceGroupType(int dayPartsRequired) {
this.dayPartsRequired = dayPartsRequired;
}
SHOW_CASE, PRE_EDUCATION, SELECTION, REGULAR, PRE_SCHOOLER
}

View File

@@ -0,0 +1,14 @@
package nl.agilecc.agiledancers.rest;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.jackson.ObjectMapperCustomizer;
import jakarta.inject.Singleton;
@Singleton
public class JacksonConfiguration implements ObjectMapperCustomizer {
@Override
public void customize(ObjectMapper mapper) {
mapper.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
}
}

View File

@@ -14,6 +14,7 @@ public class ManagementExceptionMapper implements ExceptionMapper<ManagementExce
int status = switch (exception.getKind()) {
case NOT_FOUND -> 404;
case INVALID_INPUT -> 400;
case CONFLICT -> 409;
};
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE)
.entity(new ErrorView(exception.getMessage())).build();

View File

@@ -6,6 +6,9 @@ import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.SchedulingService;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
import java.util.List;
@@ -19,6 +22,9 @@ public class ShowResource {
@Inject
SchoolManagementService service;
@Inject
SchedulingService scheduling;
@PathParam("schoolId")
long schoolId;
@@ -35,7 +41,7 @@ public class ShowResource {
@POST
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
var entity = service.createShow(schoolId, request.name(), request.date());
var entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(ShowView.of(entity)).build();
}
@@ -43,7 +49,21 @@ public class ShowResource {
@PUT
@Path("/{id}")
public ShowView update(@PathParam("id") long id, @NotNull @Valid ShowRequest request) {
return ShowView.of(service.updateShow(schoolId, id, request.name(), request.date()));
return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances()));
}
private static List<DayPart> parts(ShowRequest request) {
return request.dayParts().stream().map(p -> DayPart.builder()
.date(p.date()).type(p.type()).danceGroups(List.of()).build()).toList();
}
@POST
@Path("/{id}/schedule")
@Consumes(MediaType.WILDCARD)
public SchedulingView schedule(@PathParam("id") long id) {
var result = scheduling.schedule(schoolId, id);
return new SchedulingView(result.optimal(), result.optimal()
? "Optimal balance" : "Valid result; optimal balance not proven");
}
@DELETE

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.time.LocalDate;
import jakarta.validation.constraints.NotNull;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
public record DayPartRequest(@NotNull LocalDate date, @NotNull DayPartType type) {}

View File

@@ -1,6 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import java.util.List;
public record DayPartView(DayPartType type, List<Long> danceGroupIds) {}
import java.time.LocalDate;
public record DayPartView(LocalDate date, DayPartType type, List<Long> danceGroupIds) {}

View File

@@ -0,0 +1,2 @@
package nl.agilecc.agiledancers.rest.apimodel;
public record SchedulingView(boolean optimal, String message) {}

View File

@@ -1,7 +1,7 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
public record ShowRequest(@NotBlank String name, @NotNull LocalDate date) {}
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.*;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowRequest(@NotBlank String name, @NotNull List<@NotNull @Valid DayPartRequest> dayParts,
Map<DanceGroupType, Integer> appearances) {}

View File

@@ -1,14 +1,10 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.DanceGroup;
import java.time.LocalDate;
import java.util.List;
public record ShowView(long id, String name, LocalDate date, List<DayPartView> dayParts) {
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import java.util.*;
public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) {
public static ShowView of(Show show) {
return new ShowView(show.getId(), show.getName(), show.getDate(), show.getDayParts().stream()
.map(dayPart -> new DayPartView(dayPart.getType(), dayPart.getDanceGroups().stream()
.map(DanceGroup::getId).toList())).toList());
return new ShowView(show.getId(), show.getName(), show.getDayParts().stream()
.map(p -> new DayPartView(p.getDate(), p.getType(), p.getDanceGroups().stream().map(DanceGroup::getId).toList())).toList(), show.getAppearances());
}
}

View File

@@ -1,7 +1,7 @@
package nl.agilecc.agiledancers.service;
public class ManagementException extends RuntimeException {
public enum Kind { NOT_FOUND, INVALID_INPUT }
public enum Kind { NOT_FOUND, INVALID_INPUT, CONFLICT }
private final Kind kind;

View File

@@ -0,0 +1,4 @@
package nl.agilecc.agiledancers.service;
public interface SchedulingEngine {
SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input);
}

View File

@@ -0,0 +1,19 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@Singleton
public class SchedulingService {
@Inject SchoolManagementService schools;
@Inject SchedulingEngine engine;
public SchoolManagementService.SchedulingResult schedule(long schoolId, long showId) {
var input = schools.beginScheduling(schoolId, showId);
try {
var result = engine.solve(input);
schools.finishScheduling(schoolId, input, result);
return result;
} catch (ManagementException e) { throw e; }
catch (RuntimeException | LinkageError e) {
throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "Solver failed. Existing assignments were preserved. Please run scheduling again.");
} finally { schools.endScheduling(showId); }
}
}

View File

@@ -18,6 +18,52 @@ public class SchoolManagementService {
private final DanceGroupRepository groups = new DanceGroupRepository();
private final ShowRepository shows = new ShowRepository();
private final Map<Long, Long> revisions = new HashMap<>();
private final Set<Long> running = new HashSet<>();
private void touch(Show show) { revisions.merge(show.getId(), 1L, Long::sum); }
public record SchedulingInput(long showId, long revision, int dayParts, List<Long> groupIds, List<Integer> required) {}
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {}
public synchronized SchedulingInput beginScheduling(long schoolId, long showId) {
Show show = show(schoolId, showId);
if (running.contains(showId)) throw new ManagementException(CONFLICT, "Scheduling is already running for this show.");
List<DanceGroup> current = groups(schoolId);
if (current.isEmpty() || show.getDayParts().isEmpty()) throw new ManagementException(INVALID_INPUT, "Scheduling requires at least one school group and one daypart.");
ShowConfiguration.validate(show.getDayParts(), show.getAppearances());
running.add(showId);
return new SchedulingInput(showId, revisions.getOrDefault(showId, 0L), show.getDayParts().size(),
current.stream().map(DanceGroup::getId).toList(), current.stream().map(g -> show.getAppearances().get(g.getType())).toList());
}
public synchronized void finishScheduling(long schoolId, SchedulingInput input, SchedulingResult result) {
Show show;
try { show = show(schoolId, input.showId()); }
catch (ManagementException e) { throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again."); }
if (revisions.getOrDefault(input.showId(), 0L) != input.revision())
throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again.");
// Validate the complete solver output before mutating any assignments.
if (result == null || result.assignments() == null || result.assignments().size() != input.dayParts())
throw new ManagementException(INVALID_INPUT, "Solver returned an invalid result. Existing assignments were preserved.");
Map<Long, DanceGroup> current = new HashMap<>();
groups(schoolId).forEach(g -> current.put(g.getId(), g));
for (var part : result.assignments()) {
if (part == null || new HashSet<>(part).size() != part.size() || !input.groupIds().containsAll(part))
throw new ManagementException(INVALID_INPUT, "Solver returned invalid group assignments.");
}
for (int g = 0; g < input.groupIds().size(); g++) {
long id = input.groupIds().get(g);
if (result.assignments().stream().filter(p -> p.contains(id)).count() != input.required().get(g))
throw new ManagementException(INVALID_INPUT, "Solver did not satisfy every required appearance.");
}
List<DayPart> replacement = new ArrayList<>();
for (int p = 0; p < input.dayParts(); p++) {
DayPart old = show.getDayParts().get(p);
replacement.add(DayPart.builder().date(old.getDate()).type(old.getType())
.danceGroups(result.assignments().get(p).stream().map(current::get).toList()).build());
}
show.setDayParts(List.copyOf(replacement));
}
public synchronized void endScheduling(long showId) { running.remove(showId); }
public synchronized List<DanceSchool> schools() {
return schools.findAll();
}
@@ -89,6 +135,7 @@ public class SchoolManagementService {
DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type)
.startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build());
school.addDanceGroup(group);
school.getShows().forEach(this::touch);
return group;
}
@@ -97,6 +144,7 @@ public class SchoolManagementService {
DanceGroup group = group(schoolId, groupId);
validateAgeRange(startAge, endAge);
group.setCategory(category);
if (group.getType() != type) school(schoolId).getShows().forEach(this::touch);
group.setType(type);
group.setStartAge(startAge);
group.setEndAge(endAge);
@@ -107,6 +155,7 @@ public class SchoolManagementService {
public synchronized void deleteGroup(long schoolId, long groupId) {
DanceGroup group = group(schoolId, groupId);
school(schoolId).getShows().forEach(show -> show.getDayParts().forEach(part -> part.removeDanceGroup(group)));
school(schoolId).getShows().forEach(this::touch);
school(schoolId).removeDanceGroup(group);
groups.delete(groupId);
}
@@ -129,22 +178,34 @@ public class SchoolManagementService {
return member(school(schoolId).getShows(), showId, "Show");
}
public synchronized Show createShow(long schoolId, String name, LocalDate date) {
public synchronized Show createShow(long schoolId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
DanceSchool school = school(schoolId);
List<DayPart> dayParts = Arrays.stream(DayPartType.values())
.map(type -> DayPart.builder().type(type).danceGroups(List.of()).build()).toList();
Show show = shows.add(Show.builder().name(name).date(date).dayParts(dayParts).build());
if (counts == null) counts = ShowConfiguration.defaults(parts.size());
ShowConfiguration.validate(parts, counts);
Show show = shows.add(Show.builder().name(name).dayParts(List.copyOf(parts)).appearances(Map.copyOf(counts)).build());
school.addShow(show);
return show;
}
public synchronized Show updateShow(long schoolId, long showId, String name, LocalDate date) {
public synchronized Show updateShow(long schoolId, long showId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
Show show = show(schoolId, showId);
if (counts == null) counts = show.getAppearances().isEmpty() ? ShowConfiguration.defaults(parts.size()) : show.getAppearances();
ShowConfiguration.validate(parts, counts);
boolean changed = !counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
List<DayPart> retained = parts.stream().map(p -> DayPart.builder().date(p.getDate()).type(p.getType())
.danceGroups(show.getDayParts().stream().filter(old -> old.getDate().equals(p.getDate()) && old.getType() == p.getType())
.findFirst().map(DayPart::getDanceGroups).orElse(List.of())).build()).toList();
show.setName(name);
show.setDate(date);
show.setDayParts(retained);
show.setAppearances(Map.copyOf(counts));
if (changed) touch(show);
return show;
}
private static List<String> partKeys(List<DayPart> parts) {
return parts.stream().map(p -> p.getDate() + ":" + p.getType()).toList();
}
public synchronized void deleteShow(long schoolId, long showId) {
Show show = show(schoolId, showId);
school(schoolId).removeShow(show);
@@ -191,8 +252,8 @@ public class SchoolManagementService {
}
List<Show> rebuiltShows = new ArrayList<>();
for (Show show : importedShows) {
Show rebuilt = Show.builder().name(show.getName()).date(show.getDate()).dayParts(show.getDayParts().stream()
.map(p -> DayPart.builder().type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build();
Show rebuilt = Show.builder().name(show.getName()).appearances(show.getAppearances()).dayParts(show.getDayParts().stream()
.map(p -> DayPart.builder().date(p.getDate()).type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build();
rebuilt.setId(shows.nextId()); rebuiltShows.add(rebuilt);
}
DanceSchool imported = DanceSchool.builder().key(staged.getKey()).name(staged.getName())

View File

@@ -5,31 +5,31 @@ import nl.agilecc.agiledancers.model.specifications.*;
import java.time.LocalDate;
import java.util.*;
/** Version 1 file format. IDs are local to the file, never server identifiers. */
/** Version 2 file format. IDs are local to the file, never server identifiers. */
public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<Person> dancers,
List<Trainer> coaches, List<Group> groups, List<Performance> shows) {
public record Person(String id, String name, LocalDate birthDate) {}
public record Trainer(String id, String name) {}
public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge,
List<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {}
public record Part(DayPartType type, List<String> groupIds) {}
public record Performance(String id, String name, LocalDate date, List<Part> dayParts) {}
public record Part(LocalDate date, DayPartType type, List<String> groupIds) {}
public record Performance(String id, String name, List<Part> dayParts, Map<DanceGroupType, Integer> appearances) {}
public static SchoolSnapshot of(DanceSchool s) {
Map<Dancer, String> dancers = ids(s.getDancers());
Map<Coach, String> coaches = ids(s.getCoaches());
Map<DanceGroup, String> groups = ids(s.getDanceGroups());
Map<Show, String> shows = ids(s.getShows());
return new SchoolSnapshot(1, s.getKey(), s.getName(),
return new SchoolSnapshot(2, s.getKey(), s.getName(),
s.getDancers().stream().map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate())).toList(),
s.getCoaches().stream().map(c -> new Trainer(coaches.get(c), c.getName())).toList(),
s.getDanceGroups().stream().map(g -> new Group(groups.get(g), g.getCategory(), g.getType(),
g.getStartAge(), g.getEndAge(), List.copyOf(g.getStyles()),
g.getDancers().stream().map(dancers::get).toList(),
g.getCoach() == null ? null : coaches.get(g.getCoach()))).toList(),
s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(), show.getDate(),
show.getDayParts().stream().map(p -> new Part(p.getType(),
p.getDanceGroups().stream().map(groups::get).toList())).toList())).toList());
s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(),
show.getDayParts().stream().map(p -> new Part(p.getDate(), p.getType(),
p.getDanceGroups().stream().map(groups::get).toList())).toList(), show.getAppearances())).toList());
}
private static <T> Map<T, String> ids(Collection<T> values) {
@@ -39,7 +39,7 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<
}
public DanceSchool restore() {
check(Objects.equals(formatVersion, 1), "Niet-ondersteunde formatVersion: " + formatVersion);
check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
check(key != null, "key ontbreekt"); text(name, "schoolnaam");
DanceSchool school = DanceSchool.builder().key(key).name(name).build();
Map<String, Dancer> dancerMap = new LinkedHashMap<>();
@@ -73,14 +73,14 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<
for (Performance show : list(shows, "shows")) {
check(show != null, "Lege show"); text(show.id, "show-id");
check(showIds.add(show.id), "Dubbele show-id " + show.id); text(show.name, "shownaam");
check(show.date != null, "Showdatum ontbreekt voor " + show.name);
List<DayPart> parts = new ArrayList<>(); Set<DayPartType> types = new HashSet<>();
List<DayPart> parts = new ArrayList<>(); Set<String> types = new HashSet<>();
for (Part part : list(show.dayParts, "dayParts")) {
check(part != null && part.type != null && types.add(part.type), "Ongeldig of dubbel dagdeel voor " + show.name);
parts.add(DayPart.builder().type(part.type).danceGroups(unique(part.groupIds, "groupIds").stream()
check(part != null && part.type != null && part.date != null && types.add(part.date + ":" + part.type), "Ongeldig of dubbel dagdeel voor " + show.name);
parts.add(DayPart.builder().date(part.date).type(part.type).danceGroups(unique(part.groupIds, "groupIds").stream()
.map(id -> ref(groupMap, id, "Show " + show.name + " verwijst naar een onbekende groep")).toList()).build());
}
Show entity = Show.builder().name(show.name).date(show.date).dayParts(parts).build();
ShowConfiguration.validate(parts, show.appearances);
Show entity = Show.builder().name(show.name).dayParts(parts).appearances(Map.copyOf(show.appearances)).build();
entity.setId((long) showIds.size()); school.addShow(entity);
}
return school;

View File

@@ -0,0 +1,33 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import java.util.*;
public final class ShowConfiguration {
private ShowConfiguration() {}
public static Map<DanceGroupType, Integer> defaults(int n) {
Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
if (n > 0) for (var type : DanceGroupType.values()) counts.put(type, switch (type) {
case PRE_SCHOOLER -> 1;
case SHOW_CASE, PRE_EDUCATION -> n;
default -> Math.min(2, n);
});
return counts;
}
public static void validate(List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
if (parts == null || parts.size() > 6 || parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null)
|| parts.stream().map(p -> p.getDate() + ":" + p.getType()).distinct().count() != parts.size())
throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "Configure zero to six dayparts with unique date/type combinations.");
if (counts == null) invalidCounts();
if (parts.isEmpty() && counts.isEmpty()) return;
if (counts.size() != DanceGroupType.values().length
|| Arrays.stream(DanceGroupType.values()).anyMatch(t -> counts.get(t) == null || counts.get(t) < 1
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts();
}
private static void invalidCounts() {
throw new ManagementException(ManagementException.Kind.INVALID_INPUT,
"Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are retained when dayparts are removed.");
}
}

View File

@@ -0,0 +1,51 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.Loader;
import com.google.ortools.sat.*;
import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.service.*;
import java.util.*;
@Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine {
@Override
public SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input) {
Loader.loadNativeLibraries();
CpModel model = new CpModel();
int n = input.dayParts(), groups = input.groupIds().size();
BoolVar[][] assigned = new BoolVar[groups][n];
for (int g = 0; g < groups; g++) {
for (int p = 0; p < n; p++) assigned[g][p] = model.newBoolVar("g" + g + "p" + p);
model.addEquality(LinearExpr.sum(assigned[g]), input.required().get(g));
}
IntVar[] loads = new IntVar[n];
for (int p = 0; p < n; p++) {
BoolVar[] column = new BoolVar[groups];
for (int g = 0; g < groups; g++) column[g] = assigned[g][p];
loads[p] = model.newIntVar(0, groups, "load" + p);
model.addEquality(loads[p], LinearExpr.sum(column));
}
IntVar max = model.newIntVar(0, groups, "max"), min = model.newIntVar(0, groups, "min");
model.addMaxEquality(max, loads);
model.addMinEquality(min, loads);
model.minimize(LinearExpr.weightedSum(new IntVar[]{max, min}, new long[]{1, -1}));
CpSolver solver = new CpSolver();
solver.getParameters().setMaxTimeInSeconds(1800);
// Quarkus owns process shutdown; do not install a native SIGINT handler.
solver.getParameters().setCatchSigintSignal(false);
// Single-worker search with a fixed seed makes identical inputs repeatable.
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
CpSolverStatus status = solver.solve(model);
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE)
throw new ManagementException(ManagementException.Kind.INVALID_INPUT, switch (status) {
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
case UNKNOWN -> "The 30-minute limit was reached without a valid result. Existing assignments were preserved.";
default -> "Solver failed (" + status + "). Existing assignments were preserved.";
});
List<List<Long>> parts = new ArrayList<>();
for (int p = 0; p < n; p++) {
List<Long> ids = new ArrayList<>();
for (int g = 0; g < groups; g++) if (solver.booleanValue(assigned[g][p])) ids.add(input.groupIds().get(g));
parts.add(List.copyOf(ids));
}
return new SchoolManagementService.SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));
}
}

View File

@@ -1,2 +1,6 @@
quarkus.application.name=agiledancers
quarkus.http.host=0.0.0.0
# Scheduling is synchronous with a 30-minute solver limit.
quarkus.http.idle-timeout=31M
quarkus.jackson.fail-on-unknown-properties=true

View File

@@ -0,0 +1,109 @@
package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class SchedulingTest {
private final SchoolManagementService service = new SchoolManagementService();
private final long school = service.createSchool("Studio").getId();
private List<DayPart> parts(int n) {
return java.util.stream.IntStream.range(0, n).mapToObj(i -> DayPart.builder()
.date(LocalDate.of(2027, 1, 1).plusDays(i)).type(DayPartType.AFTERNOON).danceGroups(List.of()).build()).toList();
}
private DanceGroup group(DanceGroupType type) {
return service.createGroup(school, "Group", type, 0, 99, Set.of());
}
private void run(Show show) {
var input = service.beginScheduling(school, show.getId());
try { service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input)); }
finally { service.endScheduling(show.getId()); }
}
@Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
group(DanceGroupType.PRE_SCHOOLER);
var show = service.createShow(school, "Show", parts(6), null);
run(show);
var loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics();
assertEquals(1, loads.getSum()); assertEquals(0, loads.getMin()); assertEquals(1, loads.getMax());
for (var type : DanceGroupType.values()) group(type);
run(show);
for (var group : service.groups(school)) assertEquals(show.getAppearances().get(group.getType()).longValue(),
show.getDayParts().stream().filter(p -> p.getDanceGroups().contains(group)).count());
loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics();
assertTrue(loads.getMax() - loads.getMin() <= 1);
var snapshot = service.exportSchool(school);
assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore()));
}
@Test void defaultsInitialConfigurationAndPreservationOnEdits() {
var show = service.createShow(school, "Show", List.of(), null);
assertTrue(show.getAppearances().isEmpty());
service.updateShow(school, show.getId(), "Show", parts(3), null);
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
var counts = new EnumMap<>(show.getAppearances()); counts.put(DanceGroupType.PRE_SCHOOLER, 2);
service.updateShow(school, show.getId(), "Show", parts(4), counts);
assertEquals(counts, show.getAppearances());
assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null));
assertEquals(4, show.getDayParts().size());
service.updateShow(school, show.getId(), "Show", List.of(), null);
assertEquals(counts, show.getAppearances());
service.updateShow(school, show.getId(), "Show", parts(4), null);
assertEquals(counts, show.getAppearances());
}
@Test void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
var duplicate = List.of(parts(1).getFirst(), parts(1).getFirst());
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", duplicate, null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of()));
var counts = ShowConfiguration.defaults(2); counts.put(DanceGroupType.REGULAR, 3);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
counts.put(DanceGroupType.REGULAR, 0);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
}
@Test void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
var group = group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null); run(show);
var before = show.getDayParts().stream().map(DayPart::getDanceGroups).toList();
var input = service.beginScheduling(school, show.getId());
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
var result = new OrToolsSchedulingEngine().solve(input);
service.updateGroup(school, group.getId(), "Renamed", group.getType(), 0, 99, Set.of());
assertDoesNotThrow(() -> service.finishScheduling(school, input, result));
group(DanceGroupType.PRE_SCHOOLER);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
service.endScheduling(show.getId());
var fresh = service.beginScheduling(school, show.getId());
assertThrows(ManagementException.class, () -> service.finishScheduling(school, fresh,
new SchoolManagementService.SchedulingResult(false, List.of(List.of(), List.of()))));
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
// Feasible results are accepted without requiring proof of optimality.
var valid = new OrToolsSchedulingEngine().solve(fresh);
assertDoesNotThrow(() -> service.finishScheduling(school, fresh, new SchoolManagementService.SchedulingResult(false, valid.assignments())));
service.endScheduling(show.getId());
}
@Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {
group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null);
var input = service.beginScheduling(school, show.getId());
var result = new OrToolsSchedulingEngine().solve(input);
service.updateShow(school, show.getId(), "Show", parts(3), null);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.deleteShow(school, show.getId());
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.endScheduling(show.getId());
}
@Test void needsGroupsAndDayparts() {
var show = service.createShow(school, "Show", parts(1), null);
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
group(DanceGroupType.REGULAR);
var empty = service.createShow(school, "Empty", List.of(), null);
assertThrows(ManagementException.class, () -> service.beginScheduling(school, empty.getId()));
assertTrue(show.getAppearances().values().stream().allMatch(n -> n == 1));
}
}

View File

@@ -15,7 +15,7 @@ class SchoolManagementResourceTest {
private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
private static final Map<String, Object> GROUP = Map.of("category", "Teens", "type", "REGULAR",
"startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"});
private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "date", "2027-06-12");
private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "dayParts", new Object[]{Map.of("date", "2027-06-12", "type", "AFTERNOON")});
@Test
void managesSchoolAndItsResources() {
@@ -46,10 +46,10 @@ class SchoolManagementResourceTest {
long showId = create(school + "/shows", SHOW);
String show = school + "/shows/" + showId;
given().get(show).then().statusCode(200)
.body("dayParts.type", contains("MORNING", "AFTERNOON", "EVENING"))
.body("dayParts.type", contains("AFTERNOON"))
.body("dayParts.danceGroupIds", everyItem(empty()));
put(show, Map.of("name", "Summer show", "date", "2027-07-01"))
.statusCode(200).body("date", equalTo("2027-07-01"));
put(show, Map.of("name", "Summer show", "dayParts", new Object[]{Map.of("date", "2027-07-01", "type", "EVENING")}))
.statusCode(200).body("dayParts.date", contains("2027-07-01"));
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
given().get(school).then().statusCode(200).body("dancerIds", contains((int) dancerId))
.body("danceGroupIds", contains((int) groupId)).body("showIds", contains((int) showId));
@@ -152,6 +152,29 @@ class SchoolManagementResourceTest {
}
}
@Test
void schedulesAndValidatesTheNewShowConfiguration() {
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Scheduling school"));
try {
String show = school + "/shows/" + create(school + "/shows", SHOW);
given().post(show + "/schedule").then().statusCode(400);
long group = create(school + "/dance-groups", GROUP);
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
String before = given().get(show).asString();
String counts = "{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
for (String count : new String[]{"0", "2", "1.5", "null"}) {
String body = "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":" + counts.formatted(count) + "}";
put(show, body).statusCode(400);
given().get(show).then().body(equalTo(before));
}
String exported = given().get(school + "/export").then().statusCode(200).extract().asString();
given().contentType("application/json").body(exported).post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
.then().statusCode(200);
given().get(school + "/export").then().body(equalTo(exported));
} finally { given().delete(school); }
}
private static long create(String path, Object body) {
return given().contentType("application/json").body(body).post(path).then()
.statusCode(201).header("Location", matchesPattern(".*/[0-9]+$"))

View File

@@ -12,11 +12,11 @@ class SchoolSnapshotResourceTest {
long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE)
.then().statusCode(201).extract().jsonPath().getLong("id");
String json = given().get(BASE + "/" + id + "/export").then().statusCode(200)
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(1)).extract().asString();
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(2)).extract().asString();
try {
given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200)
.body("existingId", equalTo((int) id)).body("dancers", equalTo(0));
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 1", "\"formatVersion\" : 2"),
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
json.replace("\"name\" :", "\"unknown\" :"), json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) {
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id)

View File

@@ -9,12 +9,12 @@ import static org.junit.jupiter.api.Assertions.*;
class SchoolSnapshotTest {
private SchoolSnapshot complete() {
return new SchoolSnapshot(1, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))),
return new SchoolSnapshot(2, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))),
List.of(new SchoolSnapshot.Trainer("c", "Alex")),
List.of(new SchoolSnapshot.Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"),
new SchoolSnapshot.Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
List.of(new SchoolSnapshot.Performance("s", "Show", LocalDate.of(2027,1,1),
List.of(new SchoolSnapshot.Part(DayPartType.MORNING, List.of("g", "h"))))));
List.of(new SchoolSnapshot.Performance("s", "Show",
List.of(new SchoolSnapshot.Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1))));
}
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
var snapshot = complete();
@@ -56,7 +56,7 @@ class SchoolSnapshotTest {
@Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var service = new SchoolManagementService(); var valid = complete();
var original = service.importSchool(valid, null);
var bad = new SchoolSnapshot(1, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
var bad = new SchoolSnapshot(2, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
assertThrows(ManagementException.class, () -> service.preview(bad));
assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId()));
assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId()));

View File

@@ -0,0 +1,67 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
class SchedulingServiceTest {
@Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1))
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null);
var scheduling = new SchedulingService();
scheduling.schools = schools;
scheduling.engine = new OrToolsSchedulingEngine();
scheduling.schedule(school, show.getId());
var before = schools.exportSchool(school);
for (SchedulingEngine failure : List.<SchedulingEngine>of(
input -> { throw new IllegalStateException("Native failure"); },
input -> { throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "No valid result before the limit"); },
input -> null)) {
scheduling.engine = failure;
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()));
assertEquals(before, schools.exportSchool(school));
var next = schools.beginScheduling(school, show.getId());
schools.endScheduling(next.showId());
}
}
@Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1))
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null);
var scheduling = new SchedulingService(); scheduling.schools = schools;
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
scheduling.engine = input -> {
entered.countDown();
try { if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); }
return new OrToolsSchedulingEngine().solve(input);
};
try (var executor = Executors.newSingleThreadExecutor()) {
var first = executor.submit(() -> scheduling.schedule(school, show.getId()));
try {
assertTrue(entered.await(10, TimeUnit.SECONDS));
assertEquals(ManagementException.Kind.CONFLICT, assertThrows(ManagementException.class,
() -> scheduling.schedule(school, show.getId())).getKind());
schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of());
} finally { release.countDown(); }
var exception = assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS));
assertInstanceOf(ManagementException.class, exception.getCause());
assertTrue(exception.getCause().getMessage().contains("inputs changed"));
assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty());
}
scheduling.engine = new OrToolsSchedulingEngine();
scheduling.schedule(school, show.getId());
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size());
}
}