S18 Added OR Tools rules for scheduling show
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s
This commit is contained in:
42
README.md
42
README.md
@@ -154,23 +154,35 @@ dancers; deleting a school also deletes its dancers, groups and shows.
|
|||||||
|
|
||||||
Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
|
Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
|
||||||
with unique date/type combinations. The displayed date range comes from these
|
with unique date/type combinations. The displayed date range comes from these
|
||||||
dayparts. Each show stores `appearances` for every group type. Omitting counts
|
dayparts. Each show stores `preferredDayParts` (x), defaulting to two or one for
|
||||||
initializes defaults on creation or the initial daypart configuration; later
|
one daypart. Ordinary groups, including PRE_EDUCATION, perform in exactly x
|
||||||
edits preserve saved counts and reject counts outside 1..N. With no dayparts,
|
dayparts; preschool groups once; showcases every daypart. See
|
||||||
shows remain unscheduled and saved counts are retained.
|
[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints,
|
||||||
|
coach exceptions, ordering and optimization rules.
|
||||||
|
|
||||||
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` uses OR-Tools to
|
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` generates an ordered
|
||||||
assign every current school group to its required number of distinct dayparts,
|
draft using OR-Tools with a total thirty-minute time limit. A feasible result is
|
||||||
minimizing the busiest/quietest appearance gap. It replaces assignments only on
|
accepted without claiming optimality. Concurrent runs and stale results return
|
||||||
a valid, current result. The response distinguishes optimal balance from a valid
|
`409`; failures retain existing assignments. Development and production proxies
|
||||||
result whose optimality is unproven. Concurrent runs return `409`; changed inputs
|
allow 31 minutes.
|
||||||
also reject the result with `409`. Failed runs preserve existing assignments.
|
|
||||||
Scheduling runs synchronously with a 30-minute solver limit; development and
|
|
||||||
production proxies allow 31 minutes. The UI warns before edits that may invalidate
|
|
||||||
existing schedules and offers explicit scheduling again afterward.
|
|
||||||
|
|
||||||
School JSON exports use format version **2**, including dated dayparts, assignments,
|
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
|
||||||
and all appearance counts. Version 1 files are intentionally unsupported.
|
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;
|
||||||
|
unknown IDs and malformed lists are rejected. `POST .../shows/{showId}/finalize`
|
||||||
|
checks every hard rule before marking a schedule final. Show responses include
|
||||||
|
`preferredDayParts`, `finalized` and `violations`. Relevant edits invalidate final
|
||||||
|
status. The UI supports ordering and moving performances, and displays violations.
|
||||||
|
|
||||||
|
`GET .../coaches` lists coach registrations. `POST .../coaches` with
|
||||||
|
`{"dancerId":1}` registers a dancer as a coach. `PUT .../coaches/{coachId}` uses
|
||||||
|
the same body to link an existing coach. `DELETE .../coaches/{coachId}/dancer`
|
||||||
|
removes the identity link. The dancers screen supports maintaining these links.
|
||||||
|
|
||||||
|
School JSON exports use format version **3**, including x, schedule status and
|
||||||
|
coach-to-dancer links. Version 2 remains importable; its schedules migrate to
|
||||||
|
drafts with default x. Version 1 is unsupported. Legacy `appearances` fields are
|
||||||
|
retained for file compatibility; S18 scheduling uses x and fixed type exceptions.
|
||||||
|
The REST `appearances` input is deprecated; use `preferredDayParts`.
|
||||||
|
|
||||||
Run the HTTP integration tests with:
|
Run the HTTP integration tests with:
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import lombok.AccessLevel;
|
|||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
import lombok.ToString;
|
import lombok.ToString;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@@ -13,4 +14,7 @@ import lombok.ToString;
|
|||||||
public class Coach extends AbstractEntity {
|
public class Coach extends AbstractEntity {
|
||||||
@ToString.Include
|
@ToString.Include
|
||||||
private final String name;
|
private final String name;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
private Dancer dancer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ public class Show extends AbstractEntity {
|
|||||||
@Setter
|
@Setter
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
private Integer preferredDayParts;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
private boolean finalized;
|
||||||
|
|
||||||
@Setter
|
@Setter
|
||||||
private Map<DanceGroupType, Integer> appearances;
|
private Map<DanceGroupType, Integer> appearances;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
package nl.agilecc.agiledancers.model.scheduling;
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
public record GroupRequirement(long groupId, int appearances) {}
|
import java.util.Set;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||||
|
|
||||||
|
public record GroupRequirement(
|
||||||
|
long groupId,
|
||||||
|
int appearances,
|
||||||
|
boolean showcase,
|
||||||
|
Set<Long> dancerIds,
|
||||||
|
Set<DanceGroupStyle> styles,
|
||||||
|
double averageAge) {
|
||||||
|
public GroupRequirement {
|
||||||
|
dancerIds = Set.copyOf(dancerIds);
|
||||||
|
styles = Set.copyOf(styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GroupRequirement(final long groupId, final int appearances) {
|
||||||
|
this(groupId, appearances, false, Set.of(), Set.of(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Shared hard-rule validation for solver results and manually edited schedules. */
|
||||||
|
public final class ScheduleRules {
|
||||||
|
private ScheduleRules() {}
|
||||||
|
|
||||||
|
public static List<String> violations(final SchedulingInput input, final List<List<Long>> parts) {
|
||||||
|
final List<String> errors = new ArrayList<>();
|
||||||
|
if (input.dayParts() == 0 || input.preferredDayParts() < 1 || input.preferredDayParts() > input.dayParts()) {
|
||||||
|
errors.add("Configure dayparts and x between 1 and the number of dayparts.");
|
||||||
|
}
|
||||||
|
if (parts == null || parts.size() != input.dayParts() || parts.stream().anyMatch(java.util.Objects::isNull)) {
|
||||||
|
errors.add("Assignments must contain one list for every daypart.");
|
||||||
|
return List.copyOf(errors);
|
||||||
|
}
|
||||||
|
final Map<Long, GroupRequirement> groups = new HashMap<>();
|
||||||
|
input.groups().forEach((final GroupRequirement g) -> groups.put(g.groupId(), g));
|
||||||
|
for (int p = 0; p < parts.size(); p++) {
|
||||||
|
validatePart(parts.get(p), groups, p, errors);
|
||||||
|
}
|
||||||
|
appearances(input, parts, errors);
|
||||||
|
attendance(input, parts, errors);
|
||||||
|
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
|
||||||
|
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
|
||||||
|
if (max - min > 2) {
|
||||||
|
errors.add("Daypart performance counts differ by more than two.");
|
||||||
|
}
|
||||||
|
return List.copyOf(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validatePart(
|
||||||
|
final List<Long> ids, final Map<Long, GroupRequirement> groups, final int part, final List<String> errors) {
|
||||||
|
if (new HashSet<>(ids).size() != ids.size() || !groups.keySet().containsAll(ids)) {
|
||||||
|
errors.add("Daypart " + (part + 1) + " contains duplicate, unknown or empty groups.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final List<GroupRequirement> ordered = ids.stream().map(groups::get).toList();
|
||||||
|
showcases(ordered, part, errors);
|
||||||
|
gaps(ordered, part, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void showcases(final List<GroupRequirement> groups, final int part, final List<String> errors) {
|
||||||
|
final long count = groups.stream().filter(GroupRequirement::showcase).count();
|
||||||
|
if (count == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final boolean first = groups.getFirst().showcase();
|
||||||
|
final boolean last = groups.getLast().showcase();
|
||||||
|
if ((count == 1 && !first && !last) || (count >= 2 && (!first || !last))) {
|
||||||
|
errors.add("Daypart " + (part + 1) + " must place showcases at its opening/closing.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void gaps(final List<GroupRequirement> groups, final int part, final List<String> errors) {
|
||||||
|
final Map<Long, Integer> last = new HashMap<>();
|
||||||
|
for (int i = 0; i < groups.size(); i++) {
|
||||||
|
for (final long dancer : groups.get(i).dancerIds()) {
|
||||||
|
final Integer previous = last.put(dancer, i);
|
||||||
|
if (previous != null && i - previous < 3) {
|
||||||
|
errors.add("Daypart " + (part + 1) + ": dancer " + dancer
|
||||||
|
+ " needs two intervening performances before group "
|
||||||
|
+ groups.get(i).groupId() + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appearances(
|
||||||
|
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
|
||||||
|
for (final GroupRequirement group : input.groups()) {
|
||||||
|
if (parts.stream()
|
||||||
|
.filter((final List<Long> ids) -> ids.contains(group.groupId()))
|
||||||
|
.count()
|
||||||
|
!= group.appearances()) {
|
||||||
|
errors.add("Group " + group.groupId() + " requires " + group.appearances() + " dayparts.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void attendance(
|
||||||
|
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
|
||||||
|
final Set<Long> exempt = new HashSet<>(input.coachIds());
|
||||||
|
input.groups().stream()
|
||||||
|
.filter(GroupRequirement::showcase)
|
||||||
|
.forEach((final GroupRequirement g) -> exempt.addAll(g.dancerIds()));
|
||||||
|
final Map<Long, Set<Integer>> attendance = new HashMap<>();
|
||||||
|
for (final GroupRequirement group : input.groups()) {
|
||||||
|
recordAttendance(group, parts, attendance);
|
||||||
|
}
|
||||||
|
attendance.forEach((final Long dancer, final Set<Integer> dayparts) -> {
|
||||||
|
if (!exempt.contains(dancer) && dayparts.size() > input.preferredDayParts()) {
|
||||||
|
errors.add("Dancer " + dancer + " exceeds x=" + input.preferredDayParts() + " dayparts.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void recordAttendance(
|
||||||
|
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
|
||||||
|
for (int p = 0; p < parts.size(); p++) {
|
||||||
|
if (parts.get(p).contains(group.groupId())) {
|
||||||
|
for (final long dancer : group.dancerIds()) {
|
||||||
|
attendance
|
||||||
|
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
|
||||||
|
.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
package nl.agilecc.agiledancers.model.scheduling;
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public record SchedulingInput(long showId, long revision, int dayParts, List<GroupRequirement> groups) {
|
public record SchedulingInput(
|
||||||
|
long showId,
|
||||||
|
long revision,
|
||||||
|
int dayParts,
|
||||||
|
List<GroupRequirement> groups,
|
||||||
|
int preferredDayParts,
|
||||||
|
Set<Long> coachIds) {
|
||||||
public SchedulingInput {
|
public SchedulingInput {
|
||||||
groups = List.copyOf(groups);
|
groups = List.copyOf(groups);
|
||||||
|
coachIds = Set.copyOf(coachIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SchedulingInput(
|
||||||
|
final long showId, final long revision, final int dayParts, final List<GroupRequirement> groups) {
|
||||||
|
this(showId, revision, dayParts, groups, Math.min(2, dayParts), Set.of());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||||
|
|
||||||
|
public final class VarietyScore {
|
||||||
|
private VarietyScore() {}
|
||||||
|
|
||||||
|
/** Integer CP-SAT score: each equally weighted component ranges from zero to one million. */
|
||||||
|
public static long penalty(final GroupRequirement a, final GroupRequirement b) {
|
||||||
|
final Set<DanceGroupStyle> union = new HashSet<>(a.styles());
|
||||||
|
union.addAll(b.styles());
|
||||||
|
final Set<DanceGroupStyle> shared = new HashSet<>(a.styles());
|
||||||
|
shared.retainAll(b.styles());
|
||||||
|
final double style = union.isEmpty() ? 0 : (double) shared.size() / union.size();
|
||||||
|
final double age = Math.max(0, 1 - Math.abs(a.averageAge() - b.averageAge()) / 5);
|
||||||
|
return Math.round(1_000_000 * style) + Math.round(1_000_000 * age);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package nl.agilecc.agiledancers.rest;
|
||||||
|
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
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.core.MediaType;
|
||||||
|
import java.util.List;
|
||||||
|
import nl.agilecc.agiledancers.rest.apimodel.CoachRequest;
|
||||||
|
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
|
||||||
|
import nl.agilecc.agiledancers.service.CoachLinks;
|
||||||
|
|
||||||
|
@Path("/api/dance-schools/{schoolId}/coaches")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
public class CoachResource {
|
||||||
|
private @Inject CoachLinks links;
|
||||||
|
private @PathParam("schoolId") long schoolId;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
public List<CoachView> list() {
|
||||||
|
return links.list(schoolId).stream().map(CoachView::of).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
public CoachView create(final @NotNull @Valid CoachRequest request) {
|
||||||
|
return CoachView.of(links.link(schoolId, null, request.dancerId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("/{id}")
|
||||||
|
public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) {
|
||||||
|
return CoachView.of(links.link(schoolId, id, request.dancerId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("/{id}/dancer")
|
||||||
|
public void unlink(final @PathParam("id") long id) {
|
||||||
|
links.unlink(schoolId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,13 @@ import nl.agilecc.agiledancers.model.DayPart;
|
|||||||
import nl.agilecc.agiledancers.model.Show;
|
import nl.agilecc.agiledancers.model.Show;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
|
||||||
|
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
|
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
|
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
||||||
import nl.agilecc.agiledancers.service.SchedulingService;
|
import nl.agilecc.agiledancers.service.SchedulingService;
|
||||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
|
import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||||
|
|
||||||
@Path("/api/dance-schools/{schoolId}/shows")
|
@Path("/api/dance-schools/{schoolId}/shows")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@@ -34,33 +36,60 @@ public class ShowResource {
|
|||||||
|
|
||||||
private @Inject SchedulingService scheduling;
|
private @Inject SchedulingService scheduling;
|
||||||
|
|
||||||
|
private @Inject ShowSchedules schedules;
|
||||||
|
|
||||||
private @PathParam("schoolId") long schoolId;
|
private @PathParam("schoolId") long schoolId;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
public List<ShowView> list() {
|
public List<ShowView> list() {
|
||||||
return service.shows(schoolId).stream().map(ShowView::of).toList();
|
return service.shows(schoolId).stream().map(this::view).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public ShowView get(final @PathParam("id") long id) {
|
public ShowView get(final @PathParam("id") long id) {
|
||||||
return ShowView.of(service.show(schoolId, id));
|
return view(service.show(schoolId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) {
|
public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) {
|
||||||
final Show entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
|
final Show entity = schedules.configure(schoolId, null, settings(request));
|
||||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||||
.path(entity.getId().toString())
|
.path(entity.getId().toString())
|
||||||
.build())
|
.build())
|
||||||
.entity(ShowView.of(entity))
|
.entity(view(entity))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) {
|
public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) {
|
||||||
return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances()));
|
return view(schedules.configure(schoolId, id, settings(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShowView view(final Show show) {
|
||||||
|
return ShowView.of(show, schedules.violations(schoolId, show.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Show settings(final ShowRequest request) {
|
||||||
|
return Show.builder()
|
||||||
|
.name(request.name())
|
||||||
|
.preferredDayParts(request.preferredDayParts())
|
||||||
|
.dayParts(parts(request))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("/{id}/assignments")
|
||||||
|
public ShowView assignments(final @PathParam("id") long id, final @NotNull @Valid ScheduleRequest request) {
|
||||||
|
return view(schedules.saveDraft(schoolId, id, request.assignments()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/finalize")
|
||||||
|
@Consumes(MediaType.WILDCARD)
|
||||||
|
public ShowView finalizeSchedule(final @PathParam("id") long id) {
|
||||||
|
return view(schedules.finalizeSchedule(schoolId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<DayPart> parts(final ShowRequest request) {
|
private static List<DayPart> parts(final ShowRequest request) {
|
||||||
@@ -79,7 +108,7 @@ public class ShowResource {
|
|||||||
public SchedulingView schedule(final @PathParam("id") long id) {
|
public SchedulingView schedule(final @PathParam("id") long id) {
|
||||||
final SchedulingResult result = scheduling.schedule(schoolId, id);
|
final SchedulingResult result = scheduling.schedule(schoolId, id);
|
||||||
return new SchedulingView(
|
return new SchedulingView(
|
||||||
result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven");
|
result.optimal(), result.optimal() ? "Optimal schedule" : "Valid schedule; optimality not proven");
|
||||||
}
|
}
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record CoachRequest(@NotNull Long dancerId) {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
|
import nl.agilecc.agiledancers.model.Coach;
|
||||||
|
|
||||||
|
public record CoachView(long id, String name, Long dancerId) {
|
||||||
|
public static CoachView of(final Coach coach) {
|
||||||
|
return new CoachView(
|
||||||
|
coach.getId(),
|
||||||
|
coach.getName(),
|
||||||
|
coach.getDancer() == null ? null : coach.getDancer().getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package nl.agilecc.agiledancers.rest.apimodel;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record ScheduleRequest(@NotNull List<List<Long>> assignments) {
|
||||||
|
public ScheduleRequest {
|
||||||
|
assignments = assignments == null
|
||||||
|
? null
|
||||||
|
: assignments.stream()
|
||||||
|
.map((final List<Long> p) ->
|
||||||
|
p == null ? null : java.util.Collections.unmodifiableList(new java.util.ArrayList<>(p)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
|||||||
public record ShowRequest(
|
public record ShowRequest(
|
||||||
@NotBlank String name,
|
@NotBlank String name,
|
||||||
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
|
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
|
||||||
Map<DanceGroupType, Integer> appearances) {
|
Map<DanceGroupType, Integer> appearances,
|
||||||
|
Integer preferredDayParts) {
|
||||||
public ShowRequest {
|
public ShowRequest {
|
||||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||||
|
|||||||
@@ -10,13 +10,25 @@ import nl.agilecc.agiledancers.model.DayPart;
|
|||||||
import nl.agilecc.agiledancers.model.Show;
|
import nl.agilecc.agiledancers.model.Show;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) {
|
public record ShowView(
|
||||||
|
long id,
|
||||||
|
String name,
|
||||||
|
List<DayPartView> dayParts,
|
||||||
|
Map<DanceGroupType, Integer> appearances,
|
||||||
|
int preferredDayParts,
|
||||||
|
boolean finalized,
|
||||||
|
List<String> violations) {
|
||||||
public ShowView {
|
public ShowView {
|
||||||
|
violations = List.copyOf(violations);
|
||||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ShowView of(final Show show) {
|
public static ShowView of(final Show show) {
|
||||||
|
return of(show, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ShowView of(final Show show, final List<String> violations) {
|
||||||
return new ShowView(
|
return new ShowView(
|
||||||
show.getId(),
|
show.getId(),
|
||||||
show.getName(),
|
show.getName(),
|
||||||
@@ -28,6 +40,9 @@ public record ShowView(long id, String name, List<DayPartView> dayParts, Map<Dan
|
|||||||
.map(DanceGroup::getId)
|
.map(DanceGroup::getId)
|
||||||
.toList()))
|
.toList()))
|
||||||
.toList(),
|
.toList(),
|
||||||
show.getAppearances());
|
show.getAppearances(),
|
||||||
|
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
|
||||||
|
show.isFinalized(),
|
||||||
|
violations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package nl.agilecc.agiledancers.service;
|
||||||
|
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
import java.util.List;
|
||||||
|
import nl.agilecc.agiledancers.model.Coach;
|
||||||
|
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||||
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class CoachLinks {
|
||||||
|
@Inject
|
||||||
|
private SchoolManagementService schools;
|
||||||
|
|
||||||
|
public List<Coach> list(final long schoolId) {
|
||||||
|
return List.copyOf(schools.school(schoolId).getCoaches());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Coach link(final long schoolId, final Long coachId, final long dancerId) {
|
||||||
|
synchronized (schools) {
|
||||||
|
final DanceSchool school = schools.school(schoolId);
|
||||||
|
final Dancer dancer = schools.dancer(schoolId, dancerId);
|
||||||
|
final Coach coach = coachId == null ? create(school, dancer) : required(school, coachId);
|
||||||
|
coach.setDancer(dancer);
|
||||||
|
school.getShows().forEach(schools::touch);
|
||||||
|
return coach;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Coach create(final DanceSchool school, final Dancer dancer) {
|
||||||
|
final Coach coach = Coach.builder().name(dancer.getName()).build();
|
||||||
|
coach.setId(school.getCoaches().stream().mapToLong(Coach::getId).max().orElse(0) + 1);
|
||||||
|
school.addCoach(coach);
|
||||||
|
return coach;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Coach required(final DanceSchool school, final long id) {
|
||||||
|
return school.getCoaches().stream()
|
||||||
|
.filter((final Coach c) -> c.getId() == id)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new ManagementException(ManagementExceptionKind.NOT_FOUND, "Unknown coach"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void unlink(final long schoolId, final long id) {
|
||||||
|
synchronized (schools) {
|
||||||
|
final DanceSchool school = schools.school(schoolId);
|
||||||
|
required(school, id).setDancer(null);
|
||||||
|
school.getShows().forEach(schools::touch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package nl.agilecc.agiledancers.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.Period;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import nl.agilecc.agiledancers.model.Coach;
|
||||||
|
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||||
|
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||||
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
|
import nl.agilecc.agiledancers.model.Show;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
|
public final class SchedulingInputs {
|
||||||
|
private SchedulingInputs() {}
|
||||||
|
|
||||||
|
public static int preferred(final Show show) {
|
||||||
|
if (show.getPreferredDayParts() != null) {
|
||||||
|
return show.getPreferredDayParts();
|
||||||
|
}
|
||||||
|
return (show.getDayParts().size() == 1 ? 1 : 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SchedulingInput of(final DanceSchool school, final Show show, final long revision) {
|
||||||
|
final int x = preferred(show);
|
||||||
|
final LocalDate date = show.getDayParts().stream()
|
||||||
|
.map(DayPart::getDate)
|
||||||
|
.min(LocalDate::compareTo)
|
||||||
|
.orElse(LocalDate.now());
|
||||||
|
final List<GroupRequirement> groups = school.getDanceGroups().stream()
|
||||||
|
.filter((final DanceGroup g) -> !g.getDancers().isEmpty())
|
||||||
|
.map((final DanceGroup g) -> requirement(g, show, date))
|
||||||
|
.toList();
|
||||||
|
final Set<Long> coaches = school.getCoaches().stream()
|
||||||
|
.map(Coach::getDancer)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.map(Dancer::getId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
return new SchedulingInput(show.getId(), revision, show.getDayParts().size(), groups, x, coaches);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GroupRequirement requirement(final DanceGroup group, final Show show, final LocalDate date) {
|
||||||
|
final int count = switch (group.getType()) {
|
||||||
|
case PRE_SCHOOLER -> 1;
|
||||||
|
case SHOW_CASE -> show.getDayParts().size();
|
||||||
|
default -> preferred(show);
|
||||||
|
};
|
||||||
|
final double age = group.getDancers().stream()
|
||||||
|
.mapToInt((final Dancer d) ->
|
||||||
|
Period.between(d.getBirthDate(), date).getYears())
|
||||||
|
.average()
|
||||||
|
.orElse(0);
|
||||||
|
return new GroupRequirement(
|
||||||
|
group.getId(),
|
||||||
|
count,
|
||||||
|
group.getType() == DanceGroupType.SHOW_CASE,
|
||||||
|
group.getDancers().stream().map(Dancer::getId).collect(Collectors.toSet()),
|
||||||
|
group.getStyles(),
|
||||||
|
age);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import nl.agilecc.agiledancers.model.DanceSchool;
|
|||||||
import nl.agilecc.agiledancers.model.Dancer;
|
import nl.agilecc.agiledancers.model.Dancer;
|
||||||
import nl.agilecc.agiledancers.model.DayPart;
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
import nl.agilecc.agiledancers.model.Show;
|
import nl.agilecc.agiledancers.model.Show;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
@@ -41,7 +41,8 @@ public class SchoolManagementService {
|
|||||||
private final Map<Long, Long> revisions = new HashMap<>();
|
private final Map<Long, Long> revisions = new HashMap<>();
|
||||||
private final Set<Long> running = new HashSet<>();
|
private final Set<Long> running = new HashSet<>();
|
||||||
|
|
||||||
private void touch(final Show show) {
|
public synchronized void touch(final Show show) {
|
||||||
|
show.setFinalized(false);
|
||||||
revisions.merge(show.getId(), 1L, Long::sum);
|
revisions.merge(show.getId(), 1L, Long::sum);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,16 +59,11 @@ public class SchoolManagementService {
|
|||||||
ManagementExceptionKind.INVALID_INPUT,
|
ManagementExceptionKind.INVALID_INPUT,
|
||||||
"Scheduling requires at least one school group and one daypart.");
|
"Scheduling requires at least one school group and one daypart.");
|
||||||
}
|
}
|
||||||
ShowConfiguration.validate(show.getDayParts(), show.getAppearances());
|
if (SchedulingInputs.preferred(show) > show.getDayParts().size()) {
|
||||||
|
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Correct x before scheduling.");
|
||||||
|
}
|
||||||
running.add(showId);
|
running.add(showId);
|
||||||
return new SchedulingInput(
|
return SchedulingInputs.of(school(schoolId), show, revisions.getOrDefault(showId, 0L));
|
||||||
showId,
|
|
||||||
revisions.getOrDefault(showId, 0L),
|
|
||||||
show.getDayParts().size(),
|
|
||||||
current.stream()
|
|
||||||
.map((final DanceGroup g) -> new GroupRequirement(
|
|
||||||
g.getId(), show.getAppearances().get(g.getType())))
|
|
||||||
.toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates the complete result and input revision before replacing any saved assignments. */
|
/** Validates the complete result and input revision before replacing any saved assignments. */
|
||||||
@@ -97,6 +93,7 @@ public class SchoolManagementService {
|
|||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
show.setDayParts(List.copyOf(replacement));
|
show.setDayParts(List.copyOf(replacement));
|
||||||
|
touch(show);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<Long, DanceGroup> validateAssignments(
|
private Map<Long, DanceGroup> validateAssignments(
|
||||||
@@ -111,29 +108,13 @@ public class SchoolManagementService {
|
|||||||
}
|
}
|
||||||
final Map<Long, DanceGroup> current = new HashMap<>();
|
final Map<Long, DanceGroup> current = new HashMap<>();
|
||||||
groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g));
|
groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g));
|
||||||
validateParts(result.assignments(), current.keySet());
|
final List<String> violations = ScheduleRules.violations(input, result.assignments());
|
||||||
for (final GroupRequirement group : input.groups()) {
|
if (!violations.isEmpty()) {
|
||||||
final long id = group.groupId();
|
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", violations));
|
||||||
if (result.assignments().stream()
|
|
||||||
.filter((final List<Long> p) -> p.contains(id))
|
|
||||||
.count()
|
|
||||||
!= group.appearances()) {
|
|
||||||
throw new ManagementException(
|
|
||||||
ManagementExceptionKind.INVALID_INPUT, "Solver did not satisfy every required appearance.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void validateParts(final List<List<Long>> assignments, final Set<Long> groupIds) {
|
|
||||||
for (final List<Long> part : assignments) {
|
|
||||||
if (part == null || new HashSet<>(part).size() != part.size() || !groupIds.containsAll(part)) {
|
|
||||||
throw new ManagementException(
|
|
||||||
ManagementExceptionKind.INVALID_INPUT, "Solver returned invalid group assignments.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Releases the run slot after success or failure, without changing saved assignments. */
|
/** Releases the run slot after success or failure, without changing saved assignments. */
|
||||||
public synchronized void endScheduling(final long showId) {
|
public synchronized void endScheduling(final long showId) {
|
||||||
running.remove(showId);
|
running.remove(showId);
|
||||||
@@ -186,6 +167,7 @@ public class SchoolManagementService {
|
|||||||
final Dancer dancer = dancer(schoolId, dancerId);
|
final Dancer dancer = dancer(schoolId, dancerId);
|
||||||
dancer.setName(name);
|
dancer.setName(name);
|
||||||
dancer.setBirthDate(birthDate);
|
dancer.setBirthDate(birthDate);
|
||||||
|
school(schoolId).getShows().forEach(this::touch);
|
||||||
return dancer;
|
return dancer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +175,10 @@ public class SchoolManagementService {
|
|||||||
final DanceSchool school = school(schoolId);
|
final DanceSchool school = school(schoolId);
|
||||||
final Dancer dancer = dancer(schoolId, dancerId);
|
final Dancer dancer = dancer(schoolId, dancerId);
|
||||||
school.getDanceGroups().forEach((final DanceGroup group) -> group.removeDancer(dancer));
|
school.getDanceGroups().forEach((final DanceGroup group) -> group.removeDancer(dancer));
|
||||||
|
school.getCoaches().stream()
|
||||||
|
.filter((final nl.agilecc.agiledancers.model.Coach c) -> c.getDancer() == dancer)
|
||||||
|
.forEach((final nl.agilecc.agiledancers.model.Coach c) -> c.setDancer(null));
|
||||||
|
school.getShows().forEach(this::touch);
|
||||||
school.removeDancer(dancer);
|
school.removeDancer(dancer);
|
||||||
dancers.delete(dancerId);
|
dancers.delete(dancerId);
|
||||||
}
|
}
|
||||||
@@ -224,7 +210,7 @@ public class SchoolManagementService {
|
|||||||
final DanceGroup group = group(schoolId, groupId);
|
final DanceGroup group = group(schoolId, groupId);
|
||||||
validateAgeRange(details.startAge(), details.endAge());
|
validateAgeRange(details.startAge(), details.endAge());
|
||||||
group.setCategory(details.category());
|
group.setCategory(details.category());
|
||||||
if (group.getType() != details.type()) {
|
if (group.getType() != details.type() || !group.getStyles().equals(new HashSet<>(details.styles()))) {
|
||||||
school(schoolId).getShows().forEach(this::touch);
|
school(schoolId).getShows().forEach(this::touch);
|
||||||
}
|
}
|
||||||
group.setType(details.type());
|
group.setType(details.type());
|
||||||
@@ -248,11 +234,13 @@ public class SchoolManagementService {
|
|||||||
public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
||||||
final DanceGroup group = group(schoolId, groupId);
|
final DanceGroup group = group(schoolId, groupId);
|
||||||
group.addDancer(dancer(schoolId, dancerId));
|
group.addDancer(dancer(schoolId, dancerId));
|
||||||
|
school(schoolId).getShows().forEach(this::touch);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
||||||
final DanceGroup group = group(schoolId, groupId);
|
final DanceGroup group = group(schoolId, groupId);
|
||||||
group.removeDancer(dancer(schoolId, dancerId));
|
group.removeDancer(dancer(schoolId, dancerId));
|
||||||
|
school(schoolId).getShows().forEach(this::touch);
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized List<Show> shows(final long schoolId) {
|
public synchronized List<Show> shows(final long schoolId) {
|
||||||
@@ -273,6 +261,7 @@ public class SchoolManagementService {
|
|||||||
final Show show = shows.add(Show.builder()
|
final Show show = shows.add(Show.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.dayParts(List.copyOf(parts))
|
.dayParts(List.copyOf(parts))
|
||||||
|
.preferredDayParts((parts.size() == 1 ? 1 : 2))
|
||||||
.appearances(Map.copyOf(counts))
|
.appearances(Map.copyOf(counts))
|
||||||
.build());
|
.build());
|
||||||
school.addShow(show);
|
school.addShow(show);
|
||||||
@@ -292,18 +281,7 @@ public class SchoolManagementService {
|
|||||||
ShowConfiguration.validate(parts, counts);
|
ShowConfiguration.validate(parts, counts);
|
||||||
final boolean changed =
|
final boolean changed =
|
||||||
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
||||||
final List<DayPart> retained = parts.stream()
|
final List<DayPart> retained = ShowConfiguration.retainAssignments(show, parts);
|
||||||
.map((final DayPart p) -> DayPart.builder()
|
|
||||||
.date(p.getDate())
|
|
||||||
.type(p.getType())
|
|
||||||
.danceGroups(show.getDayParts().stream()
|
|
||||||
.filter((final DayPart old) ->
|
|
||||||
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
|
|
||||||
.findFirst()
|
|
||||||
.map(DayPart::getDanceGroups)
|
|
||||||
.orElse(List.of()))
|
|
||||||
.build())
|
|
||||||
.toList();
|
|
||||||
show.setName(name);
|
show.setName(name);
|
||||||
show.setDayParts(retained);
|
show.setDayParts(retained);
|
||||||
show.setAppearances(Map.copyOf(counts));
|
show.setAppearances(Map.copyOf(counts));
|
||||||
@@ -413,6 +391,7 @@ public class SchoolManagementService {
|
|||||||
final Show rebuilt = Show.builder()
|
final Show rebuilt = Show.builder()
|
||||||
.name(show.getName())
|
.name(show.getName())
|
||||||
.appearances(show.getAppearances())
|
.appearances(show.getAppearances())
|
||||||
|
.preferredDayParts(show.getPreferredDayParts())
|
||||||
.dayParts(show.getDayParts().stream()
|
.dayParts(show.getDayParts().stream()
|
||||||
.map((final DayPart p) -> DayPart.builder()
|
.map((final DayPart p) -> DayPart.builder()
|
||||||
.date(p.getDate())
|
.date(p.getDate())
|
||||||
|
|||||||
@@ -5,11 +5,27 @@ import java.util.EnumMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import nl.agilecc.agiledancers.model.DayPart;
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
|
import nl.agilecc.agiledancers.model.Show;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
public final class ShowConfiguration {
|
public final class ShowConfiguration {
|
||||||
private ShowConfiguration() {}
|
private ShowConfiguration() {}
|
||||||
|
|
||||||
|
public static List<DayPart> retainAssignments(final Show show, final List<DayPart> parts) {
|
||||||
|
return parts.stream()
|
||||||
|
.map((final DayPart p) -> DayPart.builder()
|
||||||
|
.date(p.getDate())
|
||||||
|
.type(p.getType())
|
||||||
|
.danceGroups(show.getDayParts().stream()
|
||||||
|
.filter((final DayPart old) ->
|
||||||
|
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
|
||||||
|
.findFirst()
|
||||||
|
.map(DayPart::getDanceGroups)
|
||||||
|
.orElse(List.of()))
|
||||||
|
.build())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
public static Map<DanceGroupType, Integer> defaults(final int n) {
|
public static Map<DanceGroupType, Integer> defaults(final int n) {
|
||||||
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
|
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
@@ -18,7 +34,7 @@ public final class ShowConfiguration {
|
|||||||
type,
|
type,
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case PRE_SCHOOLER -> 1;
|
case PRE_SCHOOLER -> 1;
|
||||||
case SHOW_CASE, PRE_EDUCATION -> n;
|
case SHOW_CASE -> n;
|
||||||
default -> Math.min(2, n);
|
default -> Math.min(2, n);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -43,7 +59,7 @@ public final class ShowConfiguration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void validateParts(final List<DayPart> parts) {
|
public static void validateParts(final List<DayPart> parts) {
|
||||||
if (parts == null
|
if (parts == null
|
||||||
|| parts.size() > 6
|
|| parts.size() > 6
|
||||||
|| parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null)
|
|| parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package nl.agilecc.agiledancers.service;
|
||||||
|
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||||
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
|
import nl.agilecc.agiledancers.model.Show;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class ShowSchedules {
|
||||||
|
@Inject
|
||||||
|
private SchoolManagementService schools;
|
||||||
|
|
||||||
|
public ShowSchedules() {}
|
||||||
|
|
||||||
|
public ShowSchedules(final SchoolManagementService schools) {
|
||||||
|
this.schools = schools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retains assignments by date/type; removing dayparts may retain an invalid x in the draft. */
|
||||||
|
public Show configure(final long schoolId, final Long showId, final Show settings) {
|
||||||
|
synchronized (schools) {
|
||||||
|
ShowConfiguration.validateParts(settings.getDayParts());
|
||||||
|
final Show existing = showId == null ? null : schools.show(schoolId, showId);
|
||||||
|
final int x = settings.getPreferredDayParts() == null
|
||||||
|
? (existing == null
|
||||||
|
? (settings.getDayParts().size() == 1 ? 1 : 2)
|
||||||
|
: SchedulingInputs.preferred(existing))
|
||||||
|
: settings.getPreferredDayParts();
|
||||||
|
validateX(x, existing, settings.getDayParts().size());
|
||||||
|
final Show show = existing == null
|
||||||
|
? schools.createShow(schoolId, settings.getName(), settings.getDayParts(), null)
|
||||||
|
: existing;
|
||||||
|
final boolean changed = x != SchedulingInputs.preferred(show)
|
||||||
|
|| !keys(show.getDayParts()).equals(keys(settings.getDayParts()));
|
||||||
|
show.setDayParts(ShowConfiguration.retainAssignments(show, settings.getDayParts()));
|
||||||
|
show.setName(settings.getName());
|
||||||
|
show.setPreferredDayParts(x);
|
||||||
|
if (changed) {
|
||||||
|
schools.touch(show);
|
||||||
|
}
|
||||||
|
return show;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> keys(final List<DayPart> parts) {
|
||||||
|
return parts.stream()
|
||||||
|
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateX(final int x, final Show existing, final int parts) {
|
||||||
|
if (x < 1
|
||||||
|
|| x > 6
|
||||||
|
|| (parts > 0 && x > parts && (existing == null || x != SchedulingInputs.preferred(existing)))) {
|
||||||
|
throw new ManagementException(
|
||||||
|
ManagementExceptionKind.INVALID_INPUT,
|
||||||
|
"x must be between 1 and the number of dayparts. Retained x must be corrected before scheduling.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves even rule-breaking drafts, but rejects malformed lists and foreign group references atomically. */
|
||||||
|
public Show saveDraft(final long schoolId, final long showId, final List<List<Long>> assignments) {
|
||||||
|
synchronized (schools) {
|
||||||
|
final Show show = schools.show(schoolId, showId);
|
||||||
|
if (assignments == null || assignments.size() != show.getDayParts().size()) {
|
||||||
|
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Provide every daypart.");
|
||||||
|
}
|
||||||
|
final List<DayPart> replacement = new ArrayList<>();
|
||||||
|
for (int p = 0; p < assignments.size(); p++) {
|
||||||
|
final DayPart old = show.getDayParts().get(p);
|
||||||
|
final List<Long> ids = assignments.get(p);
|
||||||
|
if (ids == null || ids.stream().anyMatch(java.util.Objects::isNull)) {
|
||||||
|
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Invalid group IDs.");
|
||||||
|
}
|
||||||
|
final List<DanceGroup> groups = ids.stream()
|
||||||
|
.map((final Long id) -> schools.group(schoolId, id))
|
||||||
|
.toList();
|
||||||
|
replacement.add(DayPart.builder()
|
||||||
|
.date(old.getDate())
|
||||||
|
.type(old.getType())
|
||||||
|
.danceGroups(groups)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
show.setDayParts(List.copyOf(replacement));
|
||||||
|
schools.touch(show);
|
||||||
|
return show;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks a schedule final only after checking all hard rules against current school data. */
|
||||||
|
public Show finalizeSchedule(final long schoolId, final long showId) {
|
||||||
|
synchronized (schools) {
|
||||||
|
final Show show = schools.show(schoolId, showId);
|
||||||
|
final List<String> errors = violations(schoolId, showId);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", errors));
|
||||||
|
}
|
||||||
|
show.setFinalized(true);
|
||||||
|
return show;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> violations(final long schoolId, final long showId) {
|
||||||
|
synchronized (schools) {
|
||||||
|
final Show show = schools.show(schoolId, showId);
|
||||||
|
final SchedulingInput input = SchedulingInputs.of(schools.school(schoolId), show, 0);
|
||||||
|
return ScheduleRules.violations(
|
||||||
|
input,
|
||||||
|
show.getDayParts().stream()
|
||||||
|
.map((final DayPart p) -> p.getDanceGroups().stream()
|
||||||
|
.map(DanceGroup::getId)
|
||||||
|
.toList())
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,21 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
|
||||||
public record Performance(String id, String name, List<Part> dayParts, Map<DanceGroupType, Integer> appearances) {
|
public record Performance(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
List<Part> dayParts,
|
||||||
|
Map<DanceGroupType, Integer> appearances,
|
||||||
|
Integer preferredDayParts,
|
||||||
|
boolean finalized) {
|
||||||
|
public Performance(
|
||||||
|
final String id,
|
||||||
|
final String name,
|
||||||
|
final List<Part> dayParts,
|
||||||
|
final Map<DanceGroupType, Integer> appearances) {
|
||||||
|
this(id, name, dayParts, appearances, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
public Performance {
|
public Performance {
|
||||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import nl.agilecc.agiledancers.service.ManagementException;
|
|||||||
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
||||||
import nl.agilecc.agiledancers.service.ShowConfiguration;
|
import nl.agilecc.agiledancers.service.ShowConfiguration;
|
||||||
|
|
||||||
/** Version 2 file format. IDs are local to the file, never server identifiers. */
|
/** Version 3 file format; version 2 imports remain supported. IDs are local to the file, never server identifiers. */
|
||||||
public record SchoolSnapshot(
|
public record SchoolSnapshot(
|
||||||
Integer formatVersion,
|
Integer formatVersion,
|
||||||
UUID key,
|
UUID key,
|
||||||
@@ -45,14 +45,15 @@ public record SchoolSnapshot(
|
|||||||
final Map<DanceGroup, String> groups = ids(s.getDanceGroups());
|
final Map<DanceGroup, String> groups = ids(s.getDanceGroups());
|
||||||
final Map<Show, String> shows = ids(s.getShows());
|
final Map<Show, String> shows = ids(s.getShows());
|
||||||
return new SchoolSnapshot(
|
return new SchoolSnapshot(
|
||||||
2,
|
3,
|
||||||
s.getKey(),
|
s.getKey(),
|
||||||
s.getName(),
|
s.getName(),
|
||||||
s.getDancers().stream()
|
s.getDancers().stream()
|
||||||
.map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
|
.map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
|
||||||
.toList(),
|
.toList(),
|
||||||
s.getCoaches().stream()
|
s.getCoaches().stream()
|
||||||
.map((final Coach c) -> new Trainer(coaches.get(c), c.getName()))
|
.map((final Coach c) -> new Trainer(
|
||||||
|
coaches.get(c), c.getName(), c.getDancer() == null ? null : dancers.get(c.getDancer())))
|
||||||
.toList(),
|
.toList(),
|
||||||
s.getDanceGroups().stream()
|
s.getDanceGroups().stream()
|
||||||
.map((final DanceGroup g) -> new Group(
|
.map((final DanceGroup g) -> new Group(
|
||||||
@@ -77,7 +78,9 @@ public record SchoolSnapshot(
|
|||||||
.map(groups::get)
|
.map(groups::get)
|
||||||
.toList()))
|
.toList()))
|
||||||
.toList(),
|
.toList(),
|
||||||
show.getAppearances()))
|
show.getAppearances(),
|
||||||
|
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
|
||||||
|
show.isFinalized()))
|
||||||
.toList());
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,12 +94,14 @@ public record SchoolSnapshot(
|
|||||||
|
|
||||||
/** Validates all references and values, then rebuilds an independent graph with temporary IDs. */
|
/** Validates all references and values, then rebuilds an independent graph with temporary IDs. */
|
||||||
public DanceSchool restore() {
|
public DanceSchool restore() {
|
||||||
check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
|
check(
|
||||||
|
Objects.equals(formatVersion, 2) || Objects.equals(formatVersion, 3),
|
||||||
|
"Niet-ondersteunde formatVersion: " + formatVersion);
|
||||||
check(key != null, "key ontbreekt");
|
check(key != null, "key ontbreekt");
|
||||||
text(name, "schoolnaam");
|
text(name, "schoolnaam");
|
||||||
final DanceSchool school = DanceSchool.builder().key(key).name(name).build();
|
final DanceSchool school = DanceSchool.builder().key(key).name(name).build();
|
||||||
final Map<String, Dancer> dancerMap = restoreDancers(school);
|
final Map<String, Dancer> dancerMap = restoreDancers(school);
|
||||||
final Map<String, Coach> coachMap = restoreCoaches(school);
|
final Map<String, Coach> coachMap = restoreCoaches(school, dancerMap);
|
||||||
final Map<String, DanceGroup> groupMap = restoreGroups(school, dancerMap, coachMap);
|
final Map<String, DanceGroup> groupMap = restoreGroups(school, dancerMap, coachMap);
|
||||||
restoreShows(school, groupMap);
|
restoreShows(school, groupMap);
|
||||||
return school;
|
return school;
|
||||||
@@ -120,12 +125,15 @@ public record SchoolSnapshot(
|
|||||||
return dancerMap;
|
return dancerMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Coach> restoreCoaches(final DanceSchool school) {
|
private Map<String, Coach> restoreCoaches(final DanceSchool school, final Map<String, Dancer> dancerMap) {
|
||||||
final Map<String, Coach> coachMap = new LinkedHashMap<>();
|
final Map<String, Coach> coachMap = new LinkedHashMap<>();
|
||||||
for (final Trainer c : list(coaches, "coaches")) {
|
for (final Trainer c : list(coaches, "coaches")) {
|
||||||
check(c != null, "Lege coach");
|
check(c != null, "Lege coach");
|
||||||
text(c.name(), "coachnaam");
|
text(c.name(), "coachnaam");
|
||||||
final Coach coach = Coach.builder().name(c.name()).build();
|
final Coach coach = Coach.builder()
|
||||||
|
.name(c.name())
|
||||||
|
.dancer(c.dancerId() == null ? null : ref(dancerMap, c.dancerId(), "Onbekende coach-danser"))
|
||||||
|
.build();
|
||||||
coach.setId((long) coachMap.size());
|
coach.setId((long) coachMap.size());
|
||||||
put(coachMap, c.id(), coach);
|
put(coachMap, c.id(), coach);
|
||||||
school.addCoach(coach);
|
school.addCoach(coach);
|
||||||
@@ -190,17 +198,24 @@ public record SchoolSnapshot(
|
|||||||
parts.add(DayPart.builder()
|
parts.add(DayPart.builder()
|
||||||
.date(part.date())
|
.date(part.date())
|
||||||
.type(part.type())
|
.type(part.type())
|
||||||
.danceGroups(unique(part.groupIds(), "groupIds").stream()
|
.danceGroups(list(part.groupIds(), "groupIds").stream()
|
||||||
.map((final String id) ->
|
.map((final String id) ->
|
||||||
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
|
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
|
||||||
.toList())
|
.toList())
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
ShowConfiguration.validate(parts, show.appearances());
|
ShowConfiguration.validateParts(parts);
|
||||||
|
final int x = show.preferredDayParts() == null ? (parts.size() == 1 ? 1 : 2) : show.preferredDayParts();
|
||||||
|
check(x >= 1 && x <= 6, "x moet tussen 1 en 6 liggen");
|
||||||
final Show entity = Show.builder()
|
final Show entity = Show.builder()
|
||||||
.name(show.name())
|
.name(show.name())
|
||||||
.dayParts(parts)
|
.dayParts(parts)
|
||||||
.appearances(Map.copyOf(show.appearances()))
|
.appearances(
|
||||||
|
show.appearances() == null
|
||||||
|
? ShowConfiguration.defaults(parts.size())
|
||||||
|
: Map.copyOf(show.appearances()))
|
||||||
|
.preferredDayParts(x)
|
||||||
|
.finalized(false)
|
||||||
.build();
|
.build();
|
||||||
entity.setId((long) showIds.size());
|
entity.setId((long) showIds.size());
|
||||||
school.addShow(entity);
|
school.addShow(entity);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
package nl.agilecc.agiledancers.service.model;
|
package nl.agilecc.agiledancers.service.model;
|
||||||
|
|
||||||
public record Trainer(String id, String name) {}
|
public record Trainer(String id, String name, String dancerId) {
|
||||||
|
public Trainer(final String id, final String name) {
|
||||||
|
this(id, name, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package nl.agilecc.agiledancers.solver;
|
package nl.agilecc.agiledancers.solver;
|
||||||
|
|
||||||
import com.google.ortools.Loader;
|
import com.google.ortools.Loader;
|
||||||
import com.google.ortools.sat.BoolVar;
|
|
||||||
import com.google.ortools.sat.CpModel;
|
import com.google.ortools.sat.CpModel;
|
||||||
import com.google.ortools.sat.CpSolver;
|
import com.google.ortools.sat.CpSolver;
|
||||||
import com.google.ortools.sat.CpSolverStatus;
|
import com.google.ortools.sat.CpSolverStatus;
|
||||||
import com.google.ortools.sat.IntVar;
|
|
||||||
import com.google.ortools.sat.LinearExpr;
|
import com.google.ortools.sat.LinearExpr;
|
||||||
import jakarta.inject.Singleton;
|
import jakarta.inject.Singleton;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||||
@@ -18,50 +15,51 @@ import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
|||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
public class OrToolsSchedulingEngine implements SchedulingEngine {
|
public class OrToolsSchedulingEngine implements SchedulingEngine {
|
||||||
|
private final double timeLimitSeconds;
|
||||||
|
|
||||||
|
public OrToolsSchedulingEngine() {
|
||||||
|
this(1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Allows callers to impose a shorter run budget, for example in automated tests. */
|
||||||
|
public OrToolsSchedulingEngine(final double timeLimitSeconds) {
|
||||||
|
this.timeLimitSeconds = timeLimitSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SchedulingResult solve(final SchedulingInput input) {
|
public SchedulingResult solve(final SchedulingInput input) {
|
||||||
|
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
|
||||||
Loader.loadNativeLibraries();
|
Loader.loadNativeLibraries();
|
||||||
final CpModel model = new CpModel();
|
final CpModel model = new CpModel();
|
||||||
final BoolVar[][] assigned = assignments(model, input);
|
final OrderedAssignments vars = OrderedAssignments.create(model, input);
|
||||||
balance(model, assigned, input.dayParts());
|
final LinearExpr attendance = SchedulingObjectives.attendance(model, input, vars);
|
||||||
|
final LinearExpr variety = SchedulingObjectives.variety(model, input, vars);
|
||||||
final CpSolver solver = new CpSolver();
|
final CpSolver solver = new CpSolver();
|
||||||
solver.getParameters().setMaxTimeInSeconds(1800);
|
|
||||||
// Quarkus owns process shutdown; do not install a native SIGINT handler.
|
|
||||||
solver.getParameters().setCatchSigintSignal(false);
|
solver.getParameters().setCatchSigintSignal(false);
|
||||||
// Single-worker search with a fixed seed makes identical inputs repeatable.
|
|
||||||
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
|
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
|
||||||
|
SchedulingResult best = null;
|
||||||
|
for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) {
|
||||||
|
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
if (best != null) {
|
||||||
|
return new SchedulingResult(false, best.assignments());
|
||||||
|
}
|
||||||
|
requireSolution(CpSolverStatus.UNKNOWN);
|
||||||
|
}
|
||||||
|
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
|
||||||
|
model.minimize(objective);
|
||||||
final CpSolverStatus status = solver.solve(model);
|
final CpSolverStatus status = solver.solve(model);
|
||||||
|
if (status == CpSolverStatus.UNKNOWN && best != null) {
|
||||||
|
return new SchedulingResult(false, best.assignments());
|
||||||
|
}
|
||||||
requireSolution(status);
|
requireSolution(status);
|
||||||
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, readAssignments(input, assigned, solver));
|
best = new SchedulingResult(status == CpSolverStatus.OPTIMAL, vars.read(input, solver));
|
||||||
|
if (status != CpSolverStatus.OPTIMAL) {
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
model.addEquality(objective, Math.round(solver.objectiveValue()));
|
||||||
private static BoolVar[][] assignments(final CpModel model, final SchedulingInput input) {
|
|
||||||
final BoolVar[][] assigned = new BoolVar[input.groups().size()][input.dayParts()];
|
|
||||||
for (int g = 0; g < assigned.length; g++) {
|
|
||||||
for (int p = 0; p < input.dayParts(); p++) {
|
|
||||||
assigned[g][p] = model.newBoolVar("g" + g + "p" + p);
|
|
||||||
}
|
}
|
||||||
model.addEquality(LinearExpr.sum(assigned[g]), input.groups().get(g).appearances());
|
return best;
|
||||||
}
|
|
||||||
return assigned;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void balance(final CpModel model, final BoolVar[][] assigned, final int dayParts) {
|
|
||||||
final int groups = assigned.length;
|
|
||||||
final IntVar[] loads = new IntVar[dayParts];
|
|
||||||
for (int p = 0; p < dayParts; p++) {
|
|
||||||
final 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));
|
|
||||||
}
|
|
||||||
final IntVar max = model.newIntVar(0, groups, "max");
|
|
||||||
final IntVar 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}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void requireSolution(final CpSolverStatus status) {
|
private static void requireSolution(final CpSolverStatus status) {
|
||||||
@@ -77,19 +75,4 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<List<Long>> readAssignments(
|
|
||||||
final SchedulingInput input, final BoolVar[][] assigned, final CpSolver solver) {
|
|
||||||
final List<List<Long>> parts = new ArrayList<>();
|
|
||||||
for (int p = 0; p < input.dayParts(); p++) {
|
|
||||||
final List<Long> ids = new ArrayList<>();
|
|
||||||
for (int g = 0; g < assigned.length; g++) {
|
|
||||||
if (solver.booleanValue(assigned[g][p])) {
|
|
||||||
ids.add(input.groups().get(g).groupId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts.add(List.copyOf(ids));
|
|
||||||
}
|
|
||||||
return List.copyOf(parts);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package nl.agilecc.agiledancers.solver;
|
||||||
|
|
||||||
|
import com.google.ortools.sat.BoolVar;
|
||||||
|
import com.google.ortools.sat.CpModel;
|
||||||
|
import com.google.ortools.sat.IntVar;
|
||||||
|
import com.google.ortools.sat.LinearExpr;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
|
|
||||||
|
/** Solver-owned variables. Negative positions are distinct sentinels for groups absent from a daypart. */
|
||||||
|
public record OrderedAssignments(BoolVar[][] present, IntVar[][] position, IntVar[] loads, IntVar spread) {
|
||||||
|
public static OrderedAssignments create(final CpModel model, final SchedulingInput input) {
|
||||||
|
final int n = input.groups().size();
|
||||||
|
final BoolVar[][] present = new BoolVar[n][input.dayParts()];
|
||||||
|
final IntVar[][] positions = new IntVar[n][input.dayParts()];
|
||||||
|
for (int g = 0; g < n; g++) {
|
||||||
|
for (int p = 0; p < input.dayParts(); p++) {
|
||||||
|
present[g][p] = model.newBoolVar("present" + g + ":" + p);
|
||||||
|
positions[g][p] = model.newIntVar(-g - 1, n - 1, "position" + g + ":" + p);
|
||||||
|
model.addGreaterOrEqual(positions[g][p], 0).onlyEnforceIf(present[g][p]);
|
||||||
|
model.addEquality(positions[g][p], -g - 1).onlyEnforceIf(present[g][p].not());
|
||||||
|
}
|
||||||
|
model.addEquality(LinearExpr.sum(present[g]), input.groups().get(g).appearances());
|
||||||
|
}
|
||||||
|
final IntVar[] loads = loads(model, present, positions, input.dayParts());
|
||||||
|
final IntVar max = model.newIntVar(0, n, "max");
|
||||||
|
final IntVar min = model.newIntVar(0, n, "min");
|
||||||
|
final IntVar spread = model.newIntVar(0, 2, "spread");
|
||||||
|
model.addMaxEquality(max, loads);
|
||||||
|
model.addMinEquality(min, loads);
|
||||||
|
model.addEquality(spread, LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1}));
|
||||||
|
final OrderedAssignments result = new OrderedAssignments(present, positions, loads, spread);
|
||||||
|
result.boundaries(model, input);
|
||||||
|
result.changingTime(model, input);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IntVar[] loads(
|
||||||
|
final CpModel model, final BoolVar[][] present, final IntVar[][] positions, final int parts) {
|
||||||
|
final IntVar[] loads = new IntVar[parts];
|
||||||
|
for (int p = 0; p < parts; p++) {
|
||||||
|
final BoolVar[] column = new BoolVar[present.length];
|
||||||
|
final IntVar[] order = new IntVar[present.length];
|
||||||
|
for (int g = 0; g < present.length; g++) {
|
||||||
|
column[g] = present[g][p];
|
||||||
|
order[g] = positions[g][p];
|
||||||
|
}
|
||||||
|
loads[p] = model.newIntVar(0, present.length, "load" + p);
|
||||||
|
model.addEquality(loads[p], LinearExpr.sum(column));
|
||||||
|
model.addAllDifferent(order);
|
||||||
|
for (int g = 0; g < present.length; g++) {
|
||||||
|
model.addLessThan(order[g], loads[p]).onlyEnforceIf(column[g]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return loads;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void boundaries(final CpModel model, final SchedulingInput input) {
|
||||||
|
final List<Integer> showcases = IntStream.range(0, input.groups().size())
|
||||||
|
.filter((final int g) -> input.groups().get(g).showcase())
|
||||||
|
.boxed()
|
||||||
|
.toList();
|
||||||
|
if (showcases.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int p = 0; p < input.dayParts(); p++) {
|
||||||
|
final List<BoolVar> first = new ArrayList<>();
|
||||||
|
final List<BoolVar> last = new ArrayList<>();
|
||||||
|
for (final int g : showcases) {
|
||||||
|
first.add(equals(model, position[g][p], LinearExpr.constant(0)));
|
||||||
|
last.add(equals(model, position[g][p], LinearExpr.affine(loads[p], 1, -1)));
|
||||||
|
}
|
||||||
|
if (showcases.size() == 1) {
|
||||||
|
model.addBoolOr(new com.google.ortools.sat.Literal[] {first.getFirst(), last.getFirst()});
|
||||||
|
} else {
|
||||||
|
model.addEquality(LinearExpr.sum(first.toArray(BoolVar[]::new)), 1);
|
||||||
|
model.addEquality(LinearExpr.sum(last.toArray(BoolVar[]::new)), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BoolVar equals(final CpModel model, final IntVar position, final LinearExpr value) {
|
||||||
|
final BoolVar equal = model.newBoolVar("boundary");
|
||||||
|
model.addEquality(position, value).onlyEnforceIf(equal);
|
||||||
|
model.addDifferent(position, value).onlyEnforceIf(equal.not());
|
||||||
|
return equal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void changingTime(final CpModel model, final SchedulingInput input) {
|
||||||
|
for (int a = 0; a < input.groups().size(); a++) {
|
||||||
|
for (int b = a + 1; b < input.groups().size(); b++) {
|
||||||
|
if (!java.util.Collections.disjoint(
|
||||||
|
input.groups().get(a).dancerIds(), input.groups().get(b).dancerIds())) {
|
||||||
|
separate(model, a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void separate(final CpModel model, final int a, final int b) {
|
||||||
|
for (int p = 0; p < loads.length; p++) {
|
||||||
|
final IntVar gap = model.newIntVar(0, present.length * 2L, "gap");
|
||||||
|
model.addAbsEquality(
|
||||||
|
gap, LinearExpr.weightedSum(new IntVar[] {position[a][p], position[b][p]}, new long[] {1, -1}));
|
||||||
|
model.addGreaterOrEqual(gap, 3).onlyEnforceIf(new BoolVar[] {present[a][p], present[b][p]});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<List<Long>> read(final SchedulingInput input, final com.google.ortools.sat.CpSolver solver) {
|
||||||
|
final List<List<Long>> result = new ArrayList<>();
|
||||||
|
for (int p = 0; p < loads.length; p++) {
|
||||||
|
final int part = p;
|
||||||
|
result.add(IntStream.range(0, present.length)
|
||||||
|
.boxed()
|
||||||
|
.filter((final Integer g) -> solver.booleanValue(present[g][part]))
|
||||||
|
.sorted(Comparator.comparingLong((final Integer g) -> solver.value(position[g][part])))
|
||||||
|
.map((final Integer g) -> input.groups().get(g).groupId())
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package nl.agilecc.agiledancers.solver;
|
||||||
|
|
||||||
|
import com.google.ortools.sat.BoolVar;
|
||||||
|
import com.google.ortools.sat.CpModel;
|
||||||
|
import com.google.ortools.sat.IntVar;
|
||||||
|
import com.google.ortools.sat.LinearExpr;
|
||||||
|
import com.google.ortools.sat.LinearExprBuilder;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
|
||||||
|
|
||||||
|
public final class SchedulingObjectives {
|
||||||
|
private SchedulingObjectives() {}
|
||||||
|
|
||||||
|
public static LinearExpr attendance(
|
||||||
|
final CpModel model, final SchedulingInput input, final OrderedAssignments vars) {
|
||||||
|
final Set<Long> dancers = new HashSet<>();
|
||||||
|
final Set<Long> showcases = new HashSet<>();
|
||||||
|
input.groups().forEach((final GroupRequirement g) -> dancers.addAll(g.dancerIds()));
|
||||||
|
input.groups().stream()
|
||||||
|
.filter(GroupRequirement::showcase)
|
||||||
|
.forEach((final GroupRequirement g) -> showcases.addAll(g.dancerIds()));
|
||||||
|
dancers.removeAll(input.coachIds());
|
||||||
|
final LinearExprBuilder penalty = LinearExpr.newBuilder();
|
||||||
|
for (final long dancer : dancers) {
|
||||||
|
final IntVar[] parts = dancerParts(model, input, vars, dancer);
|
||||||
|
if (showcases.contains(dancer)) {
|
||||||
|
final IntVar excess = model.newIntVar(0, input.dayParts(), "excess");
|
||||||
|
model.addMaxEquality(excess, new LinearExpr[] {
|
||||||
|
LinearExpr.affine(LinearExpr.sum(parts), 1, -input.preferredDayParts()), LinearExpr.constant(0)
|
||||||
|
});
|
||||||
|
penalty.add(excess);
|
||||||
|
} else {
|
||||||
|
model.addLessOrEqual(LinearExpr.sum(parts), input.preferredDayParts());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return penalty.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IntVar[] dancerParts(
|
||||||
|
final CpModel model, final SchedulingInput input, final OrderedAssignments vars, final long dancer) {
|
||||||
|
final List<Integer> groups = IntStream.range(0, input.groups().size())
|
||||||
|
.filter((final int g) -> !input.groups().get(g).showcase()
|
||||||
|
&& input.groups().get(g).dancerIds().contains(dancer))
|
||||||
|
.boxed()
|
||||||
|
.toList();
|
||||||
|
final IntVar[] result = new IntVar[input.dayParts()];
|
||||||
|
for (int p = 0; p < input.dayParts(); p++) {
|
||||||
|
final int part = p;
|
||||||
|
result[p] = model.newBoolVar("dancer" + dancer + ":" + p);
|
||||||
|
if (groups.isEmpty()) {
|
||||||
|
model.addEquality(result[p], 0);
|
||||||
|
} else {
|
||||||
|
model.addMaxEquality(
|
||||||
|
result[p],
|
||||||
|
groups.stream()
|
||||||
|
.map((final Integer g) -> vars.present()[g][part])
|
||||||
|
.toArray(IntVar[]::new));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LinearExpr variety(final CpModel model, final SchedulingInput input, final OrderedAssignments vars) {
|
||||||
|
final LinearExprBuilder penalty = LinearExpr.newBuilder();
|
||||||
|
// Every nonempty daypart has load - 1 adjacencies. Remove their common score
|
||||||
|
// to avoid exploring equivalent orders when groups have identical penalties.
|
||||||
|
final long baseline = minimumPenalty(input.groups());
|
||||||
|
final long performances =
|
||||||
|
input.groups().stream().mapToLong(GroupRequirement::appearances).sum();
|
||||||
|
penalty.add(performances * baseline);
|
||||||
|
for (final IntVar load : vars.loads()) {
|
||||||
|
final BoolVar active = model.newBoolVar("nonemptyPart");
|
||||||
|
model.addGreaterThan(load, 0).onlyEnforceIf(active);
|
||||||
|
model.addEquality(load, 0).onlyEnforceIf(active.not());
|
||||||
|
penalty.addTerm(active, -baseline);
|
||||||
|
}
|
||||||
|
for (int a = 0; a < input.groups().size(); a++) {
|
||||||
|
for (int b = a + 1; b < input.groups().size(); b++) {
|
||||||
|
final long score = VarietyScore.penalty(
|
||||||
|
input.groups().get(a), input.groups().get(b))
|
||||||
|
- baseline;
|
||||||
|
if (score > 0) {
|
||||||
|
addAdjacencies(model, vars, new int[] {a, b}, penalty, score);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return penalty.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long minimumPenalty(final List<GroupRequirement> groups) {
|
||||||
|
long minimum = 2_000_000;
|
||||||
|
for (int a = 0; a < groups.size(); a++) {
|
||||||
|
for (int b = a + 1; b < groups.size(); b++) {
|
||||||
|
minimum = Math.min(minimum, VarietyScore.penalty(groups.get(a), groups.get(b)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups.size() < 2 ? 0 : minimum;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addAdjacencies(
|
||||||
|
final CpModel model,
|
||||||
|
final OrderedAssignments vars,
|
||||||
|
final int[] pair,
|
||||||
|
final LinearExprBuilder penalty,
|
||||||
|
final long score) {
|
||||||
|
for (int p = 0; p < vars.loads().length; p++) {
|
||||||
|
penalty.addTerm(adjacent(model, vars, pair[0], pair[1], p), score);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BoolVar adjacent(
|
||||||
|
final CpModel model, final OrderedAssignments vars, final int a, final int b, final int p) {
|
||||||
|
final IntVar gap = model.newIntVar(0, vars.present().length * 2L, "adjacencyGap");
|
||||||
|
model.addAbsEquality(
|
||||||
|
gap,
|
||||||
|
LinearExpr.weightedSum(
|
||||||
|
new IntVar[] {vars.position()[a][p], vars.position()[b][p]}, new long[] {1, -1}));
|
||||||
|
final BoolVar adjacent = model.newBoolVar("adjacent");
|
||||||
|
model.addImplication(adjacent, vars.present()[a][p]);
|
||||||
|
model.addImplication(adjacent, vars.present()[b][p]);
|
||||||
|
model.addEquality(gap, 1).onlyEnforceIf(adjacent);
|
||||||
|
model.addDifferent(gap, 1).onlyEnforceIf(new com.google.ortools.sat.Literal[] {
|
||||||
|
adjacent.not(), vars.present()[a][p], vars.present()[b][p]
|
||||||
|
});
|
||||||
|
return adjacent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||||
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
|
import nl.agilecc.agiledancers.model.Show;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||||
|
import nl.agilecc.agiledancers.service.ManagementException;
|
||||||
|
import nl.agilecc.agiledancers.service.SchedulingInputs;
|
||||||
|
import nl.agilecc.agiledancers.service.SchedulingService;
|
||||||
|
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||||
|
import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||||
|
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||||
|
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
||||||
|
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class S18LifecycleTest {
|
||||||
|
private final SchoolManagementService schools = new SchoolManagementService();
|
||||||
|
private final long school = schools.createSchool("Studio").getId();
|
||||||
|
private final ShowSchedules schedules = new ShowSchedules(schools);
|
||||||
|
|
||||||
|
private List<DayPart> parts(final int n) {
|
||||||
|
return java.util.stream.IntStream.range(0, n)
|
||||||
|
.mapToObj((final int i) -> DayPart.builder()
|
||||||
|
.date(LocalDate.of(2027, 1, 1).plusDays(i))
|
||||||
|
.type(DayPartType.MORNING)
|
||||||
|
.danceGroups(List.of())
|
||||||
|
.build())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Show settings(final int n, final Integer x) {
|
||||||
|
return Show.builder()
|
||||||
|
.name("Show")
|
||||||
|
.dayParts(parts(n))
|
||||||
|
.preferredDayParts(x)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DanceGroup group(final DanceGroupType type) {
|
||||||
|
final DanceGroup group = schools.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
|
||||||
|
final long dancer =
|
||||||
|
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 2)).getId();
|
||||||
|
schools.addGroupDancer(school, group.getId(), dancer);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void draftEditingValidationFinalizationAndImportPreserveOrder() {
|
||||||
|
final DanceGroup group = group(DanceGroupType.REGULAR);
|
||||||
|
final Show show = schedules.configure(school, null, settings(2, 1));
|
||||||
|
assertThrows(ManagementException.class, () -> schedules.finalizeSchedule(school, show.getId()));
|
||||||
|
schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId()), List.of()));
|
||||||
|
assertTrue(schedules.violations(school, show.getId()).isEmpty());
|
||||||
|
schedules.finalizeSchedule(school, show.getId());
|
||||||
|
assertTrue(show.isFinalized());
|
||||||
|
final SchoolSnapshot snapshot = schools.exportSchool(school);
|
||||||
|
assertTrue(snapshot.shows().getFirst().finalized());
|
||||||
|
final Show imported = snapshot.restore().getShows().iterator().next();
|
||||||
|
assertFalse(imported.isFinalized());
|
||||||
|
assertEquals(1, imported.getPreferredDayParts());
|
||||||
|
assertEquals(1, imported.getDayParts().getFirst().getDanceGroups().size());
|
||||||
|
schedules.configure(school, show.getId(), settings(2, 1));
|
||||||
|
assertTrue(show.isFinalized());
|
||||||
|
schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(group.getId())));
|
||||||
|
assertFalse(show.isFinalized());
|
||||||
|
schedules.finalizeSchedule(school, show.getId());
|
||||||
|
schools.removeGroupDancer(
|
||||||
|
school, group.getId(), group.getDancers().iterator().next().getId());
|
||||||
|
assertFalse(show.isFinalized());
|
||||||
|
assertFalse(schedules.violations(school, show.getId()).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void invalidDraftsAreSavedButStructuralErrorsAreAtomic() {
|
||||||
|
final DanceGroup group = group(DanceGroupType.REGULAR);
|
||||||
|
final Show show = schedules.configure(school, null, settings(2, 1));
|
||||||
|
schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId(), group.getId()), List.of()));
|
||||||
|
assertFalse(schedules.violations(school, show.getId()).isEmpty());
|
||||||
|
final SchoolSnapshot before = schools.exportSchool(school);
|
||||||
|
assertThrows(ManagementException.class, () -> schedules.saveDraft(school, show.getId(), List.of()));
|
||||||
|
assertThrows(
|
||||||
|
ManagementException.class,
|
||||||
|
() -> schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(999L))));
|
||||||
|
assertEquals(before, schools.exportSchool(school));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void xDefaultsRetainedOnDaypartReductionAndInvalidConfigurationIsAtomic() {
|
||||||
|
final Show one = schedules.configure(school, null, settings(1, null));
|
||||||
|
assertEquals(1, one.getPreferredDayParts());
|
||||||
|
final Show show = schedules.configure(school, null, settings(3, null));
|
||||||
|
assertEquals(2, show.getPreferredDayParts());
|
||||||
|
schedules.configure(school, show.getId(), settings(1, null));
|
||||||
|
assertEquals(2, show.getPreferredDayParts());
|
||||||
|
assertThrows(ManagementException.class, () -> schools.beginScheduling(school, show.getId()));
|
||||||
|
schedules.configure(school, show.getId(), settings(1, 1));
|
||||||
|
assertTrue(schedules.violations(school, show.getId()).isEmpty());
|
||||||
|
final int before = schools.shows(school).size();
|
||||||
|
assertThrows(ManagementException.class, () -> schedules.configure(school, null, settings(1, 2)));
|
||||||
|
assertEquals(before, schools.shows(school).size());
|
||||||
|
schedules.configure(school, show.getId(), settings(0, null));
|
||||||
|
assertFalse(schedules.violations(school, show.getId()).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void timeoutPreservesAssignmentsAndIsNotReportedAsInfeasible() {
|
||||||
|
group(DanceGroupType.REGULAR);
|
||||||
|
final Show show = schedules.configure(school, null, settings(1, 1));
|
||||||
|
new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
|
||||||
|
final SchoolSnapshot before = schools.exportSchool(school);
|
||||||
|
final ManagementException error = assertThrows(
|
||||||
|
ManagementException.class,
|
||||||
|
() -> new SchedulingService(schools, new OrToolsSchedulingEngine(0)).schedule(school, show.getId()));
|
||||||
|
assertTrue(error.getMessage().contains("limit"));
|
||||||
|
assertFalse(error.getMessage().contains("infeasible"));
|
||||||
|
assertEquals(before, schools.exportSchool(school));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void emptyGroupsSkippedAndAgeUsesFirstShowDateAcrossDates() {
|
||||||
|
final DanceGroup ordinary = group(DanceGroupType.PRE_EDUCATION);
|
||||||
|
group(DanceGroupType.PRE_SCHOOLER);
|
||||||
|
schools.createGroup(school, new GroupDetails("Empty", DanceGroupType.SHOW_CASE, 0, 99, Set.of()));
|
||||||
|
final Show show = schedules.configure(school, null, settings(3, 1));
|
||||||
|
final SchedulingInput input = schools.beginScheduling(school, show.getId());
|
||||||
|
assertEquals(2, input.groups().size());
|
||||||
|
assertEquals(26, input.groups().getFirst().averageAge());
|
||||||
|
assertEquals(1, input.groups().getFirst().appearances());
|
||||||
|
schools.endScheduling(show.getId());
|
||||||
|
new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
|
||||||
|
assertFalse(show.isFinalized());
|
||||||
|
final SchedulingInput stale = schools.beginScheduling(school, show.getId());
|
||||||
|
final SchedulingResult result = new OrToolsSchedulingEngine().solve(stale);
|
||||||
|
schools.updateDancer(
|
||||||
|
school, ordinary.getDancers().iterator().next().getId(), "Dancer", LocalDate.of(2001, 1, 1));
|
||||||
|
assertThrows(ManagementException.class, () -> schools.finishScheduling(school, stale, result));
|
||||||
|
schools.endScheduling(show.getId());
|
||||||
|
assertEquals(
|
||||||
|
2, SchedulingInputs.of(schools.school(school), show, 0).groups().size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.Matchers.empty;
|
||||||
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class S18ResourceTest {
|
||||||
|
private static final String ROOT = "/api/dance-schools";
|
||||||
|
|
||||||
|
private static long create(final String url, final Object body) {
|
||||||
|
return given().contentType("application/json")
|
||||||
|
.body(body)
|
||||||
|
.post(url)
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.jsonPath()
|
||||||
|
.getLong("id");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long dancer(final String school, final String name) {
|
||||||
|
return create(school + "/dancers", Map.of("name", name, "birthDate", "1990-01-01"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long group(final String school, final long dancer) {
|
||||||
|
final long group = create(
|
||||||
|
school + "/dance-groups",
|
||||||
|
Map.of("category", "Group", "type", "REGULAR", "startAge", 0, "endAge", 99, "styles", List.of()));
|
||||||
|
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
|
||||||
|
.then()
|
||||||
|
.statusCode(204);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String show(final String school) {
|
||||||
|
return school + "/shows/"
|
||||||
|
+ create(
|
||||||
|
school + "/shows",
|
||||||
|
Map.of(
|
||||||
|
"name",
|
||||||
|
"Show",
|
||||||
|
"preferredDayParts",
|
||||||
|
1,
|
||||||
|
"dayParts",
|
||||||
|
List.of(Map.of("date", "2027-01-01", "type", "MORNING"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void draftApiFinalizesOnlyValidSchedulesAndRelevantEditsInvalidateThem() {
|
||||||
|
final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 lifecycle"));
|
||||||
|
try {
|
||||||
|
final long dancer = dancer(school, "Dancer");
|
||||||
|
final long group = group(school, dancer);
|
||||||
|
final String show = show(school);
|
||||||
|
given().post(show + "/finalize").then().statusCode(400);
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body(Map.of("assignments", List.of(List.of(group))))
|
||||||
|
.put(show + "/assignments")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("finalized", equalTo(false), "violations", empty());
|
||||||
|
given().post(show + "/finalize").then().statusCode(200).body("finalized", equalTo(true));
|
||||||
|
given().delete(school + "/dance-groups/" + group + "/dancers/" + dancer)
|
||||||
|
.then()
|
||||||
|
.statusCode(204);
|
||||||
|
given().get(show).then().body("finalized", equalTo(false), "violations", hasSize(1));
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body(Map.of("assignments", List.of(List.of(group, group))))
|
||||||
|
.put(show + "/assignments")
|
||||||
|
.then()
|
||||||
|
.statusCode(200);
|
||||||
|
final String snapshot = given().get(school + "/export").asString();
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body(snapshot)
|
||||||
|
.post(ROOT + "/import/preview")
|
||||||
|
.then()
|
||||||
|
.statusCode(200);
|
||||||
|
} finally {
|
||||||
|
given().delete(school).then().statusCode(204);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void coachIdentityIsExplicitSchoolScopedAndRoundTrips() {
|
||||||
|
final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 coaches"));
|
||||||
|
try {
|
||||||
|
final long dancer = dancer(school, "Coach");
|
||||||
|
group(school, dancer);
|
||||||
|
final String show = show(school);
|
||||||
|
given().post(show + "/schedule").then().statusCode(200);
|
||||||
|
given().post(show + "/finalize").then().statusCode(200);
|
||||||
|
final long coach = given().contentType("application/json")
|
||||||
|
.body(Map.of("dancerId", dancer))
|
||||||
|
.post(school + "/coaches")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.jsonPath()
|
||||||
|
.getLong("id");
|
||||||
|
given().get(show).then().body("finalized", equalTo(false));
|
||||||
|
given().get(school + "/coaches").then().body("[0].dancerId", equalTo((int) dancer));
|
||||||
|
final String exported = given().get(school + "/export").asString();
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body(exported)
|
||||||
|
.post(ROOT + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
|
||||||
|
.then()
|
||||||
|
.statusCode(200);
|
||||||
|
given().get(school + "/export").then().body("coaches[0].dancerId", equalTo("1"));
|
||||||
|
final long importedDancer =
|
||||||
|
given().get(school + "/dancers").jsonPath().getLong("[0].id");
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body(Map.of("dancerId", importedDancer))
|
||||||
|
.put(school + "/coaches/0")
|
||||||
|
.then()
|
||||||
|
.statusCode(200);
|
||||||
|
given().delete(school + "/coaches/0/dancer").then().statusCode(204);
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body(Map.of("dancerId", -1))
|
||||||
|
.post(school + "/coaches")
|
||||||
|
.then()
|
||||||
|
.statusCode(404);
|
||||||
|
given().contentType("application/json")
|
||||||
|
.body(Map.of("dancerId", importedDancer))
|
||||||
|
.put(school + "/coaches/9999")
|
||||||
|
.then()
|
||||||
|
.statusCode(404);
|
||||||
|
} finally {
|
||||||
|
given().delete(school).then().statusCode(204);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package nl.agilecc.agiledancers;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
|
||||||
|
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||||
|
import nl.agilecc.agiledancers.service.ManagementException;
|
||||||
|
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class S18SolverTest {
|
||||||
|
private GroupRequirement group(final long id, final int count, final boolean showcase, final long dancer) {
|
||||||
|
return new GroupRequirement(id, count, showcase, Set.of(dancer), Set.of(), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SchedulingResult solve(final SchedulingInput input) {
|
||||||
|
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input);
|
||||||
|
assertTrue(ScheduleRules.violations(input, result.assignments()).isEmpty());
|
||||||
|
assertTrue(result.optimal());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void showcasesBookendEveryPartAndSharedDancersHaveChangingSpace() {
|
||||||
|
final List<GroupRequirement> groups = List.of(
|
||||||
|
group(1, 2, true, 1),
|
||||||
|
group(2, 2, true, 2),
|
||||||
|
group(3, 2, true, 3),
|
||||||
|
group(4, 1, false, 1),
|
||||||
|
group(5, 1, false, 5),
|
||||||
|
group(6, 1, false, 6),
|
||||||
|
group(7, 1, false, 7));
|
||||||
|
final SchedulingInput input = new SchedulingInput(1, 0, 2, groups, 1, Set.of());
|
||||||
|
final SchedulingResult result = solve(input);
|
||||||
|
for (final List<Long> part : result.assignments()) {
|
||||||
|
assertTrue(part.getFirst() <= 3);
|
||||||
|
assertTrue(part.getLast() <= 3);
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
result.assignments().stream()
|
||||||
|
.filter((final List<Long> p) -> p.contains(4L))
|
||||||
|
.count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void ordinaryDancerMustShareDaypartsAcrossTheirGroups() {
|
||||||
|
final SchedulingInput input = new SchedulingInput(
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
List.of(group(1, 1, false, 1), group(2, 1, false, 1), group(3, 1, false, 3), group(4, 1, false, 4)),
|
||||||
|
1,
|
||||||
|
Set.of());
|
||||||
|
assertThrows(ManagementException.class, () -> solve(input));
|
||||||
|
final SchedulingInput coach = new SchedulingInput(1, 0, 2, input.groups(), 1, Set.of(1L));
|
||||||
|
final SchedulingResult result = solve(coach);
|
||||||
|
assertTrue(result.assignments().stream().allMatch((final List<Long> p) -> p.size() == 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void showcaseExcessTakesPriorityOverPerfectBalance() {
|
||||||
|
final SchedulingInput input = new SchedulingInput(
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
List.of(
|
||||||
|
group(1, 2, true, 1),
|
||||||
|
group(2, 1, false, 1),
|
||||||
|
group(3, 1, false, 1),
|
||||||
|
group(4, 1, false, 4),
|
||||||
|
group(5, 1, false, 5),
|
||||||
|
group(6, 1, false, 6),
|
||||||
|
group(7, 1, false, 7),
|
||||||
|
group(8, 1, false, 8),
|
||||||
|
group(9, 1, false, 9),
|
||||||
|
group(10, 1, false, 10),
|
||||||
|
group(11, 1, false, 11)),
|
||||||
|
1,
|
||||||
|
Set.of());
|
||||||
|
final SchedulingResult result = solve(input);
|
||||||
|
assertTrue(result.assignments().stream().anyMatch((final List<Long> p) -> p.containsAll(List.of(2L, 3L))));
|
||||||
|
assertEquals(
|
||||||
|
2,
|
||||||
|
Math.abs(result.assignments().get(0).size()
|
||||||
|
- result.assignments().get(1).size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void changingTimeAppliesToCoachesAndShowcasesWithoutExceptions() {
|
||||||
|
final SchedulingInput input =
|
||||||
|
new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 1)), 1, Set.of(1L));
|
||||||
|
assertThrows(ManagementException.class, () -> solve(input));
|
||||||
|
assertTrue(ScheduleRules.violations(input, List.of(List.of(1L, 2L))).stream()
|
||||||
|
.anyMatch((final String s) -> s.contains("two intervening")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void singleShowcaseCanUseEitherBoundaryAndNoShowcaseHasNoBoundaryRule() {
|
||||||
|
solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 2)), 1, Set.of()));
|
||||||
|
solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void sharedValidatorRejectsMalformedAndIncompleteDrafts() {
|
||||||
|
final SchedulingInput input = new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of());
|
||||||
|
assertFalse(ScheduleRules.violations(input, null).isEmpty());
|
||||||
|
assertFalse(ScheduleRules.violations(input, List.of()).isEmpty());
|
||||||
|
assertFalse(ScheduleRules.violations(input, List.of(List.of())).isEmpty());
|
||||||
|
assertFalse(ScheduleRules.violations(input, List.of(List.of(1L, 1L))).isEmpty());
|
||||||
|
assertFalse(ScheduleRules.violations(input, List.of(List.of(99L))).isEmpty());
|
||||||
|
final SchedulingInput empty = new SchedulingInput(1, 0, 0, List.of(), 1, Set.of());
|
||||||
|
assertFalse(ScheduleRules.violations(empty, List.of()).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Test void varietyUsesStyleOverlapAndFiveYearLinearAgePenalty() {
|
||||||
|
final GroupRequirement hiphop =
|
||||||
|
new GroupRequirement(1, 1, false, Set.of(1L), Set.of(DanceGroupStyle.HIPHOP), 10);
|
||||||
|
final GroupRequirement mixed = new GroupRequirement(
|
||||||
|
2, 1, false, Set.of(2L), Set.of(DanceGroupStyle.HIPHOP, DanceGroupStyle.MODERN), 12.5);
|
||||||
|
final GroupRequirement modern =
|
||||||
|
new GroupRequirement(3, 1, false, Set.of(3L), Set.of(DanceGroupStyle.MODERN), 15);
|
||||||
|
assertEquals(1_000_000, VarietyScore.penalty(hiphop, mixed));
|
||||||
|
assertEquals(0, VarietyScore.penalty(hiphop, modern));
|
||||||
|
final List<Long> order = solve(new SchedulingInput(1, 0, 1, List.of(hiphop, mixed, modern), 1, Set.of()))
|
||||||
|
.assignments()
|
||||||
|
.getFirst();
|
||||||
|
assertEquals(1, Math.abs(order.indexOf(1L) - order.indexOf(3L)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,12 @@ class SchedulingTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private DanceGroup group(final DanceGroupType type) {
|
private DanceGroup group(final DanceGroupType type) {
|
||||||
return service.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
|
final DanceGroup group = service.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
|
||||||
|
service.addGroupDancer(
|
||||||
|
school,
|
||||||
|
group.getId(),
|
||||||
|
service.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
|
||||||
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void run(final Show show) {
|
private void run(final Show show) {
|
||||||
@@ -86,7 +91,7 @@ class SchedulingTest {
|
|||||||
final Show show = service.createShow(school, "Show", List.of(), null);
|
final Show show = service.createShow(school, "Show", List.of(), null);
|
||||||
assertTrue(show.getAppearances().isEmpty());
|
assertTrue(show.getAppearances().isEmpty());
|
||||||
service.updateShow(school, show.getId(), "Show", parts(3), null);
|
service.updateShow(school, show.getId(), "Show", parts(3), null);
|
||||||
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
|
assertEquals(2, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
|
||||||
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
|
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
|
||||||
final var counts = new EnumMap<>(show.getAppearances());
|
final var counts = new EnumMap<>(show.getAppearances());
|
||||||
counts.put(DanceGroupType.PRE_SCHOOLER, 2);
|
counts.put(DanceGroupType.PRE_SCHOOLER, 2);
|
||||||
|
|||||||
@@ -171,15 +171,17 @@ class SchoolManagementResourceTest {
|
|||||||
final String show = school + "/shows/" + create(school + "/shows", SHOW);
|
final String show = school + "/shows/" + create(school + "/shows", SHOW);
|
||||||
given().post(show + "/schedule").then().statusCode(400);
|
given().post(show + "/schedule").then().statusCode(400);
|
||||||
final long group = create(school + "/dance-groups", GROUP);
|
final long group = create(school + "/dance-groups", GROUP);
|
||||||
|
final long dancer = create(school + "/dancers", DANCER);
|
||||||
|
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
|
||||||
|
.then()
|
||||||
|
.statusCode(204);
|
||||||
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
|
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
|
||||||
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
|
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
|
||||||
final String before = given().get(show).asString();
|
final String before = given().get(show).asString();
|
||||||
final String counts =
|
for (final String count : new String[] {"0", "2", "1.5", "7"}) {
|
||||||
"{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
|
|
||||||
for (final String count : new String[] {"0", "2", "1.5", "null"}) {
|
|
||||||
final String body =
|
final String body =
|
||||||
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":"
|
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"preferredDayParts\":"
|
||||||
+ counts.formatted(count) + "}";
|
+ count + "}";
|
||||||
put(show, body).statusCode(400);
|
put(show, body).statusCode(400);
|
||||||
given().get(show).then().body(equalTo(before));
|
given().get(show).then().body(equalTo(before));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class SchoolSnapshotResourceTest {
|
|||||||
.then()
|
.then()
|
||||||
.statusCode(200)
|
.statusCode(200)
|
||||||
.header("Content-Disposition", containsString(".json"))
|
.header("Content-Disposition", containsString(".json"))
|
||||||
.body("formatVersion", equalTo(2))
|
.body("formatVersion", equalTo(3))
|
||||||
.extract()
|
.extract()
|
||||||
.asString();
|
.asString();
|
||||||
try {
|
try {
|
||||||
@@ -58,7 +58,7 @@ class SchoolSnapshotResourceTest {
|
|||||||
for (final String bad : new String[] {
|
for (final String bad : new String[] {
|
||||||
"{",
|
"{",
|
||||||
"null",
|
"null",
|
||||||
json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
|
json.replace("\"formatVersion\" : 3", "\"formatVersion\" : 1"),
|
||||||
json.replace("\"name\" :", "\"unknown\" :"),
|
json.replace("\"name\" :", "\"unknown\" :"),
|
||||||
json + " {}",
|
json + " {}",
|
||||||
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")
|
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class SnapshotCollectionsTest {
|
|||||||
@Test
|
@Test
|
||||||
public void requestCollectionsRetainTheirJsonValuesAndCannotBeMutatedThroughAliases() {
|
public void requestCollectionsRetainTheirJsonValuesAndCannotBeMutatedThroughAliases() {
|
||||||
final Map<DanceGroupType, Integer> counts = new LinkedHashMap<>(Map.of(DanceGroupType.REGULAR, 1));
|
final Map<DanceGroupType, Integer> counts = new LinkedHashMap<>(Map.of(DanceGroupType.REGULAR, 1));
|
||||||
final ShowRequest show = new ShowRequest("Show", List.of(), counts);
|
final ShowRequest show = new ShowRequest("Show", List.of(), counts, null);
|
||||||
counts.clear();
|
counts.clear();
|
||||||
assertEquals(1, show.appearances().get(DanceGroupType.REGULAR));
|
assertEquals(1, show.appearances().get(DanceGroupType.REGULAR));
|
||||||
assertThrows(
|
assertThrows(
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class SchedulingServiceTest {
|
|||||||
public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
|
public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
|
||||||
final var schools = new SchoolManagementService();
|
final var schools = new SchoolManagementService();
|
||||||
final long school = schools.createSchool("School").getId();
|
final long school = schools.createSchool("School").getId();
|
||||||
schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()));
|
populatedGroup(schools, school);
|
||||||
final Show show = schools.createShow(
|
final Show show = schools.createShow(
|
||||||
school,
|
school,
|
||||||
"Show",
|
"Show",
|
||||||
@@ -62,7 +62,7 @@ class SchedulingServiceTest {
|
|||||||
public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
|
public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
|
||||||
final var schools = new SchoolManagementService();
|
final var schools = new SchoolManagementService();
|
||||||
final long school = schools.createSchool("School").getId();
|
final long school = schools.createSchool("School").getId();
|
||||||
schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()));
|
populatedGroup(schools, school);
|
||||||
final Show show = schools.createShow(
|
final Show show = schools.createShow(
|
||||||
school,
|
school,
|
||||||
"Show",
|
"Show",
|
||||||
@@ -84,7 +84,7 @@ class SchedulingServiceTest {
|
|||||||
ManagementExceptionKind.CONFLICT,
|
ManagementExceptionKind.CONFLICT,
|
||||||
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
|
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
|
||||||
.getKind());
|
.getKind());
|
||||||
schools.createGroup(school, new GroupDetails("New group", DanceGroupType.REGULAR, 0, 99, Set.of()));
|
populatedGroup(schools, school);
|
||||||
} finally {
|
} finally {
|
||||||
release.countDown();
|
release.countDown();
|
||||||
}
|
}
|
||||||
@@ -98,6 +98,16 @@ class SchedulingServiceTest {
|
|||||||
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size());
|
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void populatedGroup(final SchoolManagementService schools, final long school) {
|
||||||
|
final long group = schools.createGroup(
|
||||||
|
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
|
||||||
|
.getId();
|
||||||
|
schools.addGroupDancer(
|
||||||
|
school,
|
||||||
|
group,
|
||||||
|
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
|
||||||
|
}
|
||||||
|
|
||||||
private static SchedulingEngine blockingEngine(final CountDownLatch entered, final CountDownLatch release) {
|
private static SchedulingEngine blockingEngine(final CountDownLatch entered, final CountDownLatch release) {
|
||||||
final SchedulingEngine engine = (final SchedulingInput input) -> {
|
final SchedulingEngine engine = (final SchedulingInput input) -> {
|
||||||
entered.countDown();
|
entered.countDown();
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ async function confirmDelete(page: Page) {
|
|||||||
|
|
||||||
test('manages a school, shared memberships and shows through the real REST API', async ({
|
test('manages a school, shared memberships and shows through the real REST API', async ({
|
||||||
page,
|
page,
|
||||||
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
const schoolName = `E2E Studio ${Date.now()}`;
|
const schoolName = `E2E Studio ${Date.now()}`;
|
||||||
const browserErrors: string[] = [];
|
const browserErrors: string[] = [];
|
||||||
@@ -107,6 +108,26 @@ test('manages a school, shared memberships and shows through the real REST API',
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await page.screenshot({ path: 'test-results/groups-desktop.png', fullPage: true });
|
await page.screenshot({ path: 'test-results/groups-desktop.png', fullPage: true });
|
||||||
|
|
||||||
|
// Two independent performances give Sam time to change between the shared groups.
|
||||||
|
for (const name of ['Modern adults', 'Streetdance adults']) {
|
||||||
|
const dancerResponse = await request.post(
|
||||||
|
`${backend}/api/dance-schools/${schoolToDelete}/dancers`,
|
||||||
|
{
|
||||||
|
data: { name, birthDate: '1990-01-01' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const dancer = (await dancerResponse.json()) as { id: number };
|
||||||
|
const groupResponse = await request.post(
|
||||||
|
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups`,
|
||||||
|
{
|
||||||
|
data: { category: name, type: 'REGULAR', startAge: 0, endAge: 99, styles: ['MODERN'] },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const group = (await groupResponse.json()) as { id: number };
|
||||||
|
await request.put(
|
||||||
|
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups/${group.id}/dancers/${dancer.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
await tab(page, 'Shows');
|
await tab(page, 'Shows');
|
||||||
await page.getByRole('button', { name: 'Show toevoegen' }).click();
|
await page.getByRole('button', { name: 'Show toevoegen' }).click();
|
||||||
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
|
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
|
||||||
@@ -120,8 +141,19 @@ test('manages a school, shared memberships and shows through the real REST API',
|
|||||||
await expect(page.locator('.date-tile')).toContainText('jul 2027');
|
await expect(page.locator('.date-tile')).toContainText('jul 2027');
|
||||||
page.once('dialog', (dialog) => dialog.accept());
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
await page.getByRole('button', { name: /Schedule groups/ }).click();
|
await page.getByRole('button', { name: /Schedule groups/ }).click();
|
||||||
await expect(page.getByText('Planning voldoet aan alle optredensaantallen.')).toBeVisible();
|
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
|
||||||
await expect(page.getByRole('status').filter({ hasText: 'Optimale balans.' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole('status').filter({ hasText: 'Optimaal conceptrooster.' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
|
||||||
|
const scheduleDialog = page.getByRole('dialog');
|
||||||
|
await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click();
|
||||||
|
await expect(scheduleDialog.getByText('Definitief', { exact: false }).first()).toBeVisible();
|
||||||
|
await scheduleDialog.getByRole('button', { name: 'Optreden verwijderen' }).first().click();
|
||||||
|
await scheduleDialog.getByRole('button', { name: 'Concept opslaan' }).click();
|
||||||
|
await expect(scheduleDialog.locator('.alert.error').first()).toBeVisible();
|
||||||
|
await expect(scheduleDialog.getByRole('button', { name: 'Definitief maken' })).toBeDisabled();
|
||||||
|
await scheduleDialog.getByRole('button', { name: 'Gereed', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Zomershow bewerken' }).click();
|
await page.getByRole('button', { name: 'Zomershow bewerken' }).click();
|
||||||
await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click();
|
await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click();
|
||||||
await page.getByLabel('Datum', { exact: true }).nth(1).fill('2027-07-02');
|
await page.getByLabel('Datum', { exact: true }).nth(1).fill('2027-07-02');
|
||||||
@@ -148,7 +180,7 @@ test('manages a school, shared memberships and shows through the real REST API',
|
|||||||
await tab(page, 'Dansers');
|
await tab(page, 'Dansers');
|
||||||
await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click();
|
await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click();
|
||||||
await confirmDelete(page);
|
await confirmDelete(page);
|
||||||
await expect(page.getByText('De dansvloer is nog leeg')).toBeVisible();
|
await expect(page.getByRole('cell', { name: 'Sam Testdanser', exact: true })).toHaveCount(0);
|
||||||
await tab(page, 'Dansgroepen');
|
await tab(page, 'Dansgroepen');
|
||||||
await expect(page.getByText(/0 dansers/)).toHaveCount(2);
|
await expect(page.getByText(/0 dansers/)).toHaveCount(2);
|
||||||
await page.getByRole('button', { name: 'Hiphop Selectie verwijderen' }).click();
|
await page.getByRole('button', { name: 'Hiphop Selectie verwijderen' }).click();
|
||||||
@@ -166,6 +198,66 @@ test('manages a school, shared memberships and shows through the real REST API',
|
|||||||
expect(browserErrors).toEqual([]);
|
expect(browserErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('links a coach and finalizes performances spread across multiple dates', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const schoolResponse = await request.post(`${backend}/api/dance-schools`, {
|
||||||
|
data: { name: `Coach show ${Date.now()}` },
|
||||||
|
});
|
||||||
|
const school = (await schoolResponse.json()) as School;
|
||||||
|
schoolToDelete = school.id;
|
||||||
|
const base = `${backend}/api/dance-schools/${school.id}`;
|
||||||
|
const dancerResponse = await request.post(`${base}/dancers`, {
|
||||||
|
data: { name: 'Dancing coach', birthDate: '1990-01-01' },
|
||||||
|
});
|
||||||
|
const dancer = (await dancerResponse.json()) as { id: number };
|
||||||
|
const groupIds: number[] = [];
|
||||||
|
for (const name of ['Preschool', 'Adults']) {
|
||||||
|
const response = await request.post(`${base}/dance-groups`, {
|
||||||
|
data: {
|
||||||
|
category: name,
|
||||||
|
type: name === 'Preschool' ? 'PRE_SCHOOLER' : 'REGULAR',
|
||||||
|
startAge: 0,
|
||||||
|
endAge: 99,
|
||||||
|
styles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const group = (await response.json()) as { id: number };
|
||||||
|
groupIds.push(group.id);
|
||||||
|
await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`);
|
||||||
|
}
|
||||||
|
const showResponse = await request.post(`${base}/shows`, {
|
||||||
|
data: {
|
||||||
|
name: 'Coach across dates',
|
||||||
|
preferredDayParts: 1,
|
||||||
|
dayParts: [
|
||||||
|
{ date: '2027-06-01', type: 'AFTERNOON' },
|
||||||
|
{ date: '2027-06-02', type: 'AFTERNOON' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const show = (await showResponse.json()) as { id: number };
|
||||||
|
await request.put(`${base}/shows/${show.id}/assignments`, {
|
||||||
|
data: { assignments: groupIds.map((id) => [id]) },
|
||||||
|
});
|
||||||
|
await page.goto(`/schools/${school.id}/shows`);
|
||||||
|
await expect(page.getByText(/exceeds x=1/)).toBeVisible();
|
||||||
|
await tab(page, 'Dansers');
|
||||||
|
await page.getByText('Coaches koppelen aan dansers', { exact: true }).click();
|
||||||
|
await page.getByLabel('Danser als coach registreren').selectOption({ label: 'Dancing coach' });
|
||||||
|
await expect(page.getByRole('combobox', { name: 'Dancing coach', exact: true })).toBeVisible();
|
||||||
|
await tab(page, 'Shows');
|
||||||
|
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }),
|
||||||
|
).toBeDisabled();
|
||||||
|
const finalResponse = await request.get(`${base}/shows/${show.id}`);
|
||||||
|
expect(((await finalResponse.json()) as { finalized: boolean }).finalized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('shows a useful connection error and recovers on retry', async ({ page }) => {
|
test('shows a useful connection error and recovers on retry', async ({ page }) => {
|
||||||
await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed'));
|
await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed'));
|
||||||
await page.goto('/schools');
|
await page.goto('/schools');
|
||||||
|
|||||||
@@ -27,11 +27,19 @@ export interface DayPart {
|
|||||||
type: DayPartType;
|
type: DayPartType;
|
||||||
danceGroupIds: number[];
|
danceGroupIds: number[];
|
||||||
}
|
}
|
||||||
|
export interface Coach {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
dancerId: number | null;
|
||||||
|
}
|
||||||
export interface Show {
|
export interface Show {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
dayParts: DayPart[];
|
dayParts: DayPart[];
|
||||||
appearances: Partial<Record<GroupType, number>>;
|
appearances: Partial<Record<GroupType, number>>;
|
||||||
|
preferredDayParts: number;
|
||||||
|
finalized: boolean;
|
||||||
|
violations: string[];
|
||||||
}
|
}
|
||||||
export interface SchoolRequest {
|
export interface SchoolRequest {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -51,6 +59,7 @@ export interface ShowRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
dayParts: { date: string; type: DayPartType }[];
|
dayParts: { date: string; type: DayPartType }[];
|
||||||
appearances?: Partial<Record<GroupType, number>>;
|
appearances?: Partial<Record<GroupType, number>>;
|
||||||
|
preferredDayParts?: number;
|
||||||
}
|
}
|
||||||
export type ResourceKind = 'school' | 'dancer' | 'group' | 'show';
|
export type ResourceKind = 'school' | 'dancer' | 'group' | 'show';
|
||||||
export type EditorTarget =
|
export type EditorTarget =
|
||||||
@@ -63,6 +72,7 @@ export interface SchoolData {
|
|||||||
dancers: Dancer[];
|
dancers: Dancer[];
|
||||||
groups: DanceGroup[];
|
groups: DanceGroup[];
|
||||||
shows: Show[];
|
shows: Show[];
|
||||||
|
coaches: Coach[];
|
||||||
}
|
}
|
||||||
export const GROUP_TYPES: { value: GroupType; label: string }[] = [
|
export const GROUP_TYPES: { value: GroupType; label: string }[] = [
|
||||||
{ value: 'REGULAR', label: 'Regulier' },
|
{ value: 'REGULAR', label: 'Regulier' },
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ describe('SchoolApi contract', () => {
|
|||||||
http.expectOne('/api/dance-schools/7/dance-groups').flush([]);
|
http.expectOne('/api/dance-schools/7/dance-groups').flush([]);
|
||||||
expect(received).not.toHaveBeenCalled();
|
expect(received).not.toHaveBeenCalled();
|
||||||
http.expectOne('/api/dance-schools/7/shows').flush([]);
|
http.expectOne('/api/dance-schools/7/shows').flush([]);
|
||||||
expect(received).toHaveBeenCalledWith({ school, dancers: [], groups: [], shows: [] });
|
http.expectOne('/api/dance-schools/7/coaches').flush([]);
|
||||||
|
expect(received).toHaveBeenCalledWith({
|
||||||
|
school,
|
||||||
|
dancers: [],
|
||||||
|
groups: [],
|
||||||
|
shows: [],
|
||||||
|
coaches: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates an existing entity even when its id is zero', () => {
|
it('updates an existing entity even when its id is zero', () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core';
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { forkJoin, Observable } from 'rxjs';
|
import { forkJoin, Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
|
Coach,
|
||||||
DanceGroup,
|
DanceGroup,
|
||||||
Dancer,
|
Dancer,
|
||||||
DancerRequest,
|
DancerRequest,
|
||||||
@@ -29,6 +30,7 @@ export class SchoolApi {
|
|||||||
dancers: this.http.get<Dancer[]>(`${base}/dancers`),
|
dancers: this.http.get<Dancer[]>(`${base}/dancers`),
|
||||||
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
|
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
|
||||||
shows: this.http.get<Show[]>(`${base}/shows`),
|
shows: this.http.get<Show[]>(`${base}/shows`),
|
||||||
|
coaches: this.http.get<Coach[]>(`${base}/coaches`),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
saveSchool(body: SchoolRequest, id?: number): Observable<School> {
|
saveSchool(body: SchoolRequest, id?: number): Observable<School> {
|
||||||
@@ -52,6 +54,20 @@ export class SchoolApi {
|
|||||||
{ timeout: 1860000 },
|
{ timeout: 1860000 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> {
|
||||||
|
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, {
|
||||||
|
assignments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finalize(schoolId: number, showId: number): Observable<Show> {
|
||||||
|
return this.http.post<Show>(`${this.base}/${schoolId}/shows/${showId}/finalize`, null);
|
||||||
|
}
|
||||||
|
linkCoach(schoolId: number, dancerId: number, coachId?: number): Observable<Coach> {
|
||||||
|
return this.save<Coach>(`${this.base}/${schoolId}/coaches`, { dancerId }, coachId);
|
||||||
|
}
|
||||||
|
unlinkCoach(schoolId: number, coachId: number): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.base}/${schoolId}/coaches/${coachId}/dancer`);
|
||||||
|
}
|
||||||
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
|
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
|
||||||
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
|
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
|
||||||
kind
|
kind
|
||||||
|
|||||||
@@ -151,6 +151,7 @@
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@case ('dancers') {
|
@case ('dancers') {
|
||||||
|
<app-coach-editor [data]="context" (changed)="reload()" />
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>Dansers</h2>
|
<h2>Dansers</h2>
|
||||||
@@ -350,6 +351,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="show-details">
|
<div class="show-details">
|
||||||
<h3>{{ show.name }}</h3>
|
<h3>{{ show.name }}</h3>
|
||||||
|
<p>
|
||||||
|
{{ show.finalized ? 'Definitief' : 'Concept' }} · x = {{ show.preferredDayParts }}
|
||||||
|
</p>
|
||||||
|
@for (violation of show.violations; track $index) {
|
||||||
|
<p class="alert error">{{ violation }}</p>
|
||||||
|
}
|
||||||
|
<button type="button" class="button secondary" (click)="scheduleEditing.set(show)">
|
||||||
|
Indeling en volgorde bewerken
|
||||||
|
</button>
|
||||||
<p>{{ dateRange(show) }}</p>
|
<p>{{ dateRange(show) }}</p>
|
||||||
@for (part of show.dayParts; track $index) {
|
@for (part of show.dayParts; track $index) {
|
||||||
<p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</p>
|
<p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</p>
|
||||||
@@ -357,7 +367,7 @@
|
|||||||
<p role="status">
|
<p role="status">
|
||||||
{{
|
{{
|
||||||
scheduleValid(show)
|
scheduleValid(show)
|
||||||
? 'Planning voldoet aan alle optredensaantallen.'
|
? 'Planning voldoet aan alle harde regels.'
|
||||||
: 'Geen geldige planning. Plan de groepen opnieuw in.'
|
: 'Geen geldige planning. Plan de groepen opnieuw in.'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
@@ -365,7 +375,10 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="button primary"
|
class="button primary"
|
||||||
[disabled]="
|
[disabled]="
|
||||||
scheduling().includes(show.id) || !context.groups.length || !show.dayParts.length
|
scheduling().includes(show.id) ||
|
||||||
|
!context.groups.length ||
|
||||||
|
!show.dayParts.length ||
|
||||||
|
show.preferredDayParts > show.dayParts.length
|
||||||
"
|
"
|
||||||
(click)="schedule(show)"
|
(click)="schedule(show)"
|
||||||
>
|
>
|
||||||
@@ -426,6 +439,15 @@
|
|||||||
(saved)="saved()"
|
(saved)="saved()"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@if (scheduleEditing(); as show) {
|
||||||
|
<app-schedule-editor
|
||||||
|
[show]="show"
|
||||||
|
[groups]="context.groups"
|
||||||
|
[schoolId]="context.school.id"
|
||||||
|
(closed)="scheduleEditing.set(null)"
|
||||||
|
(saved)="reload()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
@if (members(); as group) {
|
@if (members(); as group) {
|
||||||
<app-group-members
|
<app-group-members
|
||||||
[group]="group"
|
[group]="group"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { apiError } from '../core/api-error';
|
|||||||
import { SchoolApi } from '../core/school-api';
|
import { SchoolApi } from '../core/school-api';
|
||||||
import { EntityEditor } from '../shared/entity-editor';
|
import { EntityEditor } from '../shared/entity-editor';
|
||||||
import { ConfirmDialog } from '../shared/confirm-dialog';
|
import { ConfirmDialog } from '../shared/confirm-dialog';
|
||||||
|
import { ScheduleEditor } from '../shared/schedule-editor';
|
||||||
|
import { CoachEditor } from '../shared/coach-editor';
|
||||||
import { GroupMembers } from '../shared/group-members';
|
import { GroupMembers } from '../shared/group-members';
|
||||||
|
|
||||||
interface DeleteTarget {
|
interface DeleteTarget {
|
||||||
@@ -29,6 +31,8 @@ interface DeleteTarget {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-school-page',
|
selector: 'app-school-page',
|
||||||
imports: [
|
imports: [
|
||||||
|
ScheduleEditor,
|
||||||
|
CoachEditor,
|
||||||
SchoolTransfer,
|
SchoolTransfer,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
@@ -50,6 +54,7 @@ export class SchoolPage implements OnInit {
|
|||||||
readonly error = signal('');
|
readonly error = signal('');
|
||||||
readonly notice = signal('');
|
readonly notice = signal('');
|
||||||
readonly editor = signal<EditorTarget | null>(null);
|
readonly editor = signal<EditorTarget | null>(null);
|
||||||
|
readonly scheduleEditing = signal<Show | null>(null);
|
||||||
readonly members = signal<DanceGroup | null>(null);
|
readonly members = signal<DanceGroup | null>(null);
|
||||||
readonly deleting = signal<DeleteTarget | null>(null);
|
readonly deleting = signal<DeleteTarget | null>(null);
|
||||||
readonly scheduling = signal<number[]>([]);
|
readonly scheduling = signal<number[]>([]);
|
||||||
@@ -86,6 +91,7 @@ export class SchoolPage implements OnInit {
|
|||||||
this.search.set('');
|
this.search.set('');
|
||||||
this.editor.set(null);
|
this.editor.set(null);
|
||||||
this.members.set(null);
|
this.members.set(null);
|
||||||
|
this.scheduleEditing.set(null);
|
||||||
this.deleting.set(null);
|
this.deleting.set(null);
|
||||||
this.section.set(params.get('section') ?? 'overview');
|
this.section.set(params.get('section') ?? 'overview');
|
||||||
return this.api.schoolData(Number(params.get('schoolId'))).pipe(
|
return this.api.schoolData(Number(params.get('schoolId'))).pipe(
|
||||||
@@ -121,22 +127,9 @@ export class SchoolPage implements OnInit {
|
|||||||
return !first ? 'No dates configured' : first === last ? first : `${first} – ${last}`;
|
return !first ? 'No dates configured' : first === last ? first : `${first} – ${last}`;
|
||||||
}
|
}
|
||||||
scheduleValid(show: Show): boolean {
|
scheduleValid(show: Show): boolean {
|
||||||
const groups = this.data()?.groups ?? [];
|
return show.violations?.length === 0;
|
||||||
return (
|
|
||||||
!!groups.length &&
|
|
||||||
!!show.dayParts.length &&
|
|
||||||
groups.every(
|
|
||||||
(g) =>
|
|
||||||
show.dayParts.filter((p) => p.danceGroupIds.includes(g.id)).length ===
|
|
||||||
show.appearances[g.type],
|
|
||||||
) &&
|
|
||||||
show.dayParts.every(
|
|
||||||
(p) =>
|
|
||||||
new Set(p.danceGroupIds).size === p.danceGroupIds.length &&
|
|
||||||
p.danceGroupIds.every((id) => groups.some((g) => g.id === id)),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
groupNamesFor(ids: number[]): string {
|
groupNamesFor(ids: number[]): string {
|
||||||
return (
|
return (
|
||||||
ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') ||
|
ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') ||
|
||||||
@@ -163,7 +156,9 @@ export class SchoolPage implements OnInit {
|
|||||||
try {
|
try {
|
||||||
const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
|
const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
|
||||||
this.notice.set(
|
this.notice.set(
|
||||||
result.optimal ? 'Optimale balans.' : 'Geldige planning; optimale balans niet bewezen.',
|
result.optimal
|
||||||
|
? 'Optimaal conceptrooster.'
|
||||||
|
: 'Geldig conceptrooster; optimaliteit niet bewezen.',
|
||||||
);
|
);
|
||||||
await this.reload();
|
await this.reload();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -221,6 +216,7 @@ export class SchoolPage implements OnInit {
|
|||||||
this.deleting.set(target);
|
this.deleting.set(target);
|
||||||
}
|
}
|
||||||
membershipChanged(ids: number[]): void {
|
membershipChanged(ids: number[]): void {
|
||||||
|
void this.reload();
|
||||||
const groupId = this.members()?.id;
|
const groupId = this.members()?.id;
|
||||||
this.data.update((data) =>
|
this.data.update((data) =>
|
||||||
data
|
data
|
||||||
|
|||||||
70
agiledancers-fe/src/app/shared/coach-editor.ts
Normal file
70
agiledancers-fe/src/app/shared/coach-editor.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Component, inject, input, output, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { SchoolData } from '../core/api-models';
|
||||||
|
import { SchoolApi } from '../core/school-api';
|
||||||
|
import { apiError } from '../core/api-error';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-coach-editor',
|
||||||
|
imports: [FormsModule],
|
||||||
|
template: `
|
||||||
|
<details>
|
||||||
|
<summary>Coaches koppelen aan dansers</summary>
|
||||||
|
<p>
|
||||||
|
Coaches mogen in alle dagdelen dansen. Voeg ze als danser toe aan elke groep waarin ze
|
||||||
|
meedansen. Ook voor coaches blijven twee andere optredens tussen hun dansen verplicht.
|
||||||
|
</p>
|
||||||
|
@if (error()) {
|
||||||
|
<p role="alert" class="alert error">{{ error() }}</p>
|
||||||
|
}
|
||||||
|
<fieldset [disabled]="busy()">
|
||||||
|
@for (coach of data().coaches; track coach.id) {
|
||||||
|
<label
|
||||||
|
>{{ coach.name }}
|
||||||
|
<select [ngModel]="coach.dancerId" (ngModelChange)="link($event, coach.id)">
|
||||||
|
<option [ngValue]="null">Geen danser gekoppeld</option>
|
||||||
|
@for (dancer of data().dancers; track dancer.id) {
|
||||||
|
<option [ngValue]="dancer.id">{{ dancer.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label
|
||||||
|
>Danser als coach registreren
|
||||||
|
<select [ngModel]="null" (ngModelChange)="link($event)">
|
||||||
|
<option [ngValue]="null">Kies een danser</option>
|
||||||
|
@for (dancer of data().dancers; track dancer.id) {
|
||||||
|
<option [ngValue]="dancer.id">{{ dancer.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
</details>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class CoachEditor {
|
||||||
|
readonly data = input.required<SchoolData>();
|
||||||
|
readonly changed = output<void>();
|
||||||
|
readonly busy = signal(false);
|
||||||
|
readonly error = signal('');
|
||||||
|
private readonly api = inject(SchoolApi);
|
||||||
|
|
||||||
|
async link(dancerId: number | null, coachId?: number): Promise<void> {
|
||||||
|
if (this.busy() || (dancerId === null && coachId === undefined)) return;
|
||||||
|
this.busy.set(true);
|
||||||
|
this.error.set('');
|
||||||
|
try {
|
||||||
|
await firstValueFrom<unknown>(
|
||||||
|
dancerId === null
|
||||||
|
? this.api.unlinkCoach(this.data().school.id, coachId!)
|
||||||
|
: this.api.linkCoach(this.data().school.id, dancerId, coachId),
|
||||||
|
);
|
||||||
|
this.changed.emit();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.error.set(apiError(error));
|
||||||
|
} finally {
|
||||||
|
this.busy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,9 @@ describe('EntityEditor', () => {
|
|||||||
const show: Show = {
|
const show: Show = {
|
||||||
id: 3,
|
id: 3,
|
||||||
name: 'Summer',
|
name: 'Summer',
|
||||||
|
preferredDayParts: 2,
|
||||||
|
finalized: false,
|
||||||
|
violations: [],
|
||||||
dayParts: [
|
dayParts: [
|
||||||
{ date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] },
|
{ date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] },
|
||||||
{ date: '2027-06-02', type: 'AFTERNOON', danceGroupIds: [4] },
|
{ date: '2027-06-02', type: 'AFTERNOON', danceGroupIds: [4] },
|
||||||
@@ -77,7 +80,7 @@ describe('EntityEditor', () => {
|
|||||||
appearances: { REGULAR: 2, SELECTION: 2, PRE_SCHOOLER: 1, SHOW_CASE: 2, PRE_EDUCATION: 2 },
|
appearances: { REGULAR: 2, SELECTION: 2, PRE_SCHOOLER: 1, SHOW_CASE: 2, PRE_EDUCATION: 2 },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('initializes defaults for every type and rejects duplicate dated dayparts and fractional counts', () => {
|
it('defaults x to two and rejects duplicate dated dayparts and fractional x', () => {
|
||||||
const component = editor({ kind: 'show' });
|
const component = editor({ kind: 'show' });
|
||||||
component.name = 'Show';
|
component.name = 'Show';
|
||||||
expect(component.validDetails()).toBe(true);
|
expect(component.validDetails()).toBe(true);
|
||||||
@@ -87,31 +90,25 @@ describe('EntityEditor', () => {
|
|||||||
component.dayParts[1].date = '2027-06-02';
|
component.dayParts[1].date = '2027-06-02';
|
||||||
component.addPart();
|
component.addPart();
|
||||||
component.dayParts[2].date = '2027-06-03';
|
component.dayParts[2].date = '2027-06-03';
|
||||||
expect(component.appearances).toEqual({
|
expect(component.preferredDayParts).toBe(2);
|
||||||
REGULAR: 2,
|
|
||||||
SELECTION: 2,
|
|
||||||
PRE_SCHOOLER: 1,
|
|
||||||
SHOW_CASE: 3,
|
|
||||||
PRE_EDUCATION: 3,
|
|
||||||
});
|
|
||||||
expect(component.validDetails()).toBe(true);
|
expect(component.validDetails()).toBe(true);
|
||||||
component.dayParts[2].date = '2027-06-02';
|
component.dayParts[2].date = '2027-06-02';
|
||||||
expect(component.validDetails()).toBe(false);
|
expect(component.validDetails()).toBe(false);
|
||||||
component.dayParts[2].date = '2027-06-03';
|
component.dayParts[2].date = '2027-06-03';
|
||||||
component.appearances.PRE_SCHOOLER = 1.5;
|
component.preferredDayParts = 1.5;
|
||||||
expect(component.validDetails()).toBe(false);
|
expect(component.validDetails()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves saved counts and requires explicit correction when reducing to one daypart', () => {
|
it('retains x when dayparts are removed so it can be explicitly corrected', () => {
|
||||||
const component = editor({ kind: 'show', value: show });
|
const component = editor({ kind: 'show', value: show });
|
||||||
component.addPart();
|
component.addPart();
|
||||||
component.dayParts[2].date = '2027-06-03';
|
component.dayParts[2].date = '2027-06-03';
|
||||||
expect(component.appearances.SHOW_CASE).toBe(2);
|
expect(component.preferredDayParts).toBe(2);
|
||||||
component.removePart(2);
|
component.removePart(2);
|
||||||
component.removePart(1);
|
component.removePart(1);
|
||||||
expect(component.appearances.REGULAR).toBe(2);
|
expect(component.preferredDayParts).toBe(2);
|
||||||
expect(component.validDetails()).toBe(false);
|
expect(component.validDetails()).toBe(true);
|
||||||
component.setSingleCounts();
|
component.preferredDayParts = 1;
|
||||||
expect(component.validDetails()).toBe(true);
|
expect(component.validDetails()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,11 +117,11 @@ describe('EntityEditor', () => {
|
|||||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
try {
|
try {
|
||||||
const component = editor({ kind: 'show', value: show });
|
const component = editor({ kind: 'show', value: show });
|
||||||
component.appearances.REGULAR = 1;
|
component.preferredDayParts = 1;
|
||||||
await component.save();
|
await component.save();
|
||||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
|
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
|
||||||
expect(api.saveShow).not.toHaveBeenCalled();
|
expect(api.saveShow).not.toHaveBeenCalled();
|
||||||
component.appearances.REGULAR = 2;
|
component.preferredDayParts = 2;
|
||||||
confirm.mockClear();
|
confirm.mockClear();
|
||||||
component.name = 'Renamed';
|
component.name = 'Renamed';
|
||||||
await component.save();
|
await component.save();
|
||||||
@@ -147,7 +144,7 @@ describe('EntityEditor', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays one disabled appearance field for every type with one daypart', async () => {
|
it('displays one editable x field with a default of one for a one-daypart show', async () => {
|
||||||
// jsdom does not implement native dialog opening.
|
// jsdom does not implement native dialog opening.
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal');
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal');
|
||||||
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
|
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
|
||||||
@@ -163,8 +160,8 @@ describe('EntityEditor', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
const inputs = Array.from(element.querySelectorAll<HTMLInputElement>('input[type="number"]'));
|
const inputs = Array.from(element.querySelectorAll<HTMLInputElement>('input[type="number"]'));
|
||||||
expect(inputs).toHaveLength(5);
|
expect(inputs).toHaveLength(1);
|
||||||
expect(inputs.every((input) => input.disabled && input.value === '1')).toBe(true);
|
expect(inputs.every((input) => !input.disabled && input.value === '1')).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor);
|
if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor);
|
||||||
else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal');
|
else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal');
|
||||||
|
|||||||
@@ -130,33 +130,22 @@ import { DialogFrame } from './dialog-frame';
|
|||||||
</button>
|
</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@if (dayParts.length) {
|
@if (dayParts.length) {
|
||||||
<fieldset>
|
|
||||||
<legend>Optredens per groepstype</legend>
|
|
||||||
@for (option of types; track option.value) {
|
|
||||||
<label
|
<label
|
||||||
>{{ option.label
|
>Gewenst aantal dagdelen (x)
|
||||||
}}<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
[name]="'count' + option.value"
|
name="preferredDayParts"
|
||||||
[ngModel]="dayParts.length === 1 ? 1 : appearances[option.value]"
|
[(ngModel)]="preferredDayParts"
|
||||||
(ngModelChange)="appearances[option.value] = $event"
|
|
||||||
required
|
|
||||||
min="1"
|
min="1"
|
||||||
[max]="dayParts.length"
|
max="6"
|
||||||
step="1"
|
step="1"
|
||||||
[disabled]="dayParts.length === 1"
|
required
|
||||||
/></label>
|
/>
|
||||||
}
|
</label>
|
||||||
<p class="form-hint">
|
<p class="form-hint">
|
||||||
Bestaande aantallen blijven behouden bij wijzigingen. Corrigeer te hoge aantallen
|
Gewone groepen dansen in precies x dagdelen. Kleuters eenmaal; showcases ieder
|
||||||
vóór opslaan.
|
dagdeel. Is x groter dan het aantal dagdelen, corrigeer x vóór inplannen.
|
||||||
</p>
|
</p>
|
||||||
@if (dayParts.length === 1 && !validCounts()) {
|
|
||||||
<button type="button" class="button secondary" (click)="setSingleCounts()">
|
|
||||||
Alle aantallen op één zetten
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</fieldset>
|
|
||||||
} @else {
|
} @else {
|
||||||
<p class="form-hint">Zonder dagdelen blijft deze show ongepland.</p>
|
<p class="form-hint">Zonder dagdelen blijft deze show ongepland.</p>
|
||||||
}
|
}
|
||||||
@@ -196,8 +185,7 @@ export class EntityEditor implements OnInit {
|
|||||||
readonly today = new Date().toLocaleDateString('en-CA');
|
readonly today = new Date().toLocaleDateString('en-CA');
|
||||||
private readonly api = inject(SchoolApi);
|
private readonly api = inject(SchoolApi);
|
||||||
dayParts: DayPart[] = [];
|
dayParts: DayPart[] = [];
|
||||||
appearances: Partial<Record<GroupType, number>> = {};
|
preferredDayParts = 2;
|
||||||
private countsInitialized = false;
|
|
||||||
name = '';
|
name = '';
|
||||||
date = '';
|
date = '';
|
||||||
type: GroupType = 'REGULAR';
|
type: GroupType = 'REGULAR';
|
||||||
@@ -223,44 +211,23 @@ export class EntityEditor implements OnInit {
|
|||||||
if (target.kind === 'dancer') this.date = target.value.birthDate;
|
if (target.kind === 'dancer') this.date = target.value.birthDate;
|
||||||
if (target.kind === 'show') {
|
if (target.kind === 'show') {
|
||||||
this.dayParts = target.value.dayParts.map((p) => ({ ...p }));
|
this.dayParts = target.value.dayParts.map((p) => ({ ...p }));
|
||||||
this.appearances = { ...target.value.appearances };
|
this.preferredDayParts = target.value.preferredDayParts;
|
||||||
this.countsInitialized = Object.keys(this.appearances).length > 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
addPart(): void {
|
addPart(): void {
|
||||||
if (this.dayParts.length >= 6) return;
|
if (this.dayParts.length >= 6) return;
|
||||||
const previous = this.dayParts.length;
|
|
||||||
this.dayParts.push({ date: '', type: 'AFTERNOON', danceGroupIds: [] });
|
this.dayParts.push({ date: '', type: 'AFTERNOON', danceGroupIds: [] });
|
||||||
if (!this.countsInitialized) {
|
if (!this.target().value) this.preferredDayParts = Math.min(2, this.dayParts.length);
|
||||||
for (const { value } of this.types) {
|
|
||||||
const defaultCount = (n: number) =>
|
|
||||||
value === 'PRE_SCHOOLER'
|
|
||||||
? 1
|
|
||||||
: value === 'SHOW_CASE' || value === 'PRE_EDUCATION'
|
|
||||||
? n
|
|
||||||
: Math.min(2, n);
|
|
||||||
if (
|
|
||||||
this.appearances[value] === undefined ||
|
|
||||||
this.appearances[value] === defaultCount(previous)
|
|
||||||
)
|
|
||||||
this.appearances[value] = defaultCount(this.dayParts.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
removePart(index: number): void {
|
removePart(index: number): void {
|
||||||
this.dayParts.splice(index, 1);
|
this.dayParts.splice(index, 1);
|
||||||
if (!this.countsInitialized && !this.dayParts.length) this.appearances = {};
|
|
||||||
}
|
|
||||||
setSingleCounts(): void {
|
|
||||||
for (const option of this.types) this.appearances[option.value] = 1;
|
|
||||||
}
|
}
|
||||||
validCounts(): boolean {
|
validCounts(): boolean {
|
||||||
return this.types.every(
|
return (
|
||||||
(o) =>
|
Number.isInteger(this.preferredDayParts) &&
|
||||||
Number.isInteger(this.appearances[o.value]) &&
|
this.preferredDayParts >= 1 &&
|
||||||
this.appearances[o.value]! >= 1 &&
|
this.preferredDayParts <= 6
|
||||||
this.appearances[o.value]! <= this.dayParts.length,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
toggleStyle(style: GroupStyle, checked: boolean): void {
|
toggleStyle(style: GroupStyle, checked: boolean): void {
|
||||||
@@ -300,7 +267,7 @@ export class EntityEditor implements OnInit {
|
|||||||
current.value &&
|
current.value &&
|
||||||
(JSON.stringify(current.value.dayParts.map((p) => ({ date: p.date, type: p.type }))) !==
|
(JSON.stringify(current.value.dayParts.map((p) => ({ date: p.date, type: p.type }))) !==
|
||||||
JSON.stringify(this.dayParts.map((p) => ({ date: p.date, type: p.type }))) ||
|
JSON.stringify(this.dayParts.map((p) => ({ date: p.date, type: p.type }))) ||
|
||||||
JSON.stringify(current.value.appearances) !== JSON.stringify(this.appearances))
|
current.value.preferredDayParts !== this.preferredDayParts)
|
||||||
)
|
)
|
||||||
affected = [current.value];
|
affected = [current.value];
|
||||||
const names = affected
|
const names = affected
|
||||||
@@ -349,7 +316,7 @@ export class EntityEditor implements OnInit {
|
|||||||
{
|
{
|
||||||
name: this.name.trim(),
|
name: this.name.trim(),
|
||||||
dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })),
|
dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })),
|
||||||
appearances: this.appearances,
|
preferredDayParts: this.preferredDayParts,
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
|
|||||||
88
agiledancers-fe/src/app/shared/schedule-editor.spec.ts
Normal file
88
agiledancers-fe/src/app/shared/schedule-editor.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Show, DanceGroup } from '../core/api-models';
|
||||||
|
import { SchoolApi } from '../core/school-api';
|
||||||
|
import { ScheduleEditor } from './schedule-editor';
|
||||||
|
|
||||||
|
const groups: DanceGroup[] = [1, 2, 3].map((id) => ({
|
||||||
|
id,
|
||||||
|
category: `Group ${id}`,
|
||||||
|
type: 'REGULAR',
|
||||||
|
startAge: 0,
|
||||||
|
endAge: 99,
|
||||||
|
styles: [],
|
||||||
|
dancerIds: [id],
|
||||||
|
}));
|
||||||
|
const show: Show = {
|
||||||
|
id: 4,
|
||||||
|
name: 'Show',
|
||||||
|
preferredDayParts: 1,
|
||||||
|
finalized: true,
|
||||||
|
violations: [],
|
||||||
|
appearances: {},
|
||||||
|
dayParts: [
|
||||||
|
{ date: '2027-01-01', type: 'MORNING', danceGroupIds: [1, 2] },
|
||||||
|
{ date: '2027-01-02', type: 'MORNING', danceGroupIds: [3] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ScheduleEditor', () => {
|
||||||
|
const api = { saveAssignments: vi.fn(), finalize: vi.fn() };
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
TestBed.configureTestingModule({ providers: [{ provide: SchoolApi, useValue: api }] });
|
||||||
|
});
|
||||||
|
function editor() {
|
||||||
|
const fixture = TestBed.createComponent(ScheduleEditor);
|
||||||
|
fixture.componentRef.setInput('show', show);
|
||||||
|
fixture.componentRef.setInput('groups', groups);
|
||||||
|
fixture.componentRef.setInput('schoolId', 7);
|
||||||
|
fixture.componentInstance.ngOnInit();
|
||||||
|
return fixture.componentInstance;
|
||||||
|
}
|
||||||
|
it('edits order and dayparts without changing the saved input', () => {
|
||||||
|
const component = editor();
|
||||||
|
component.reorder(0, 1, -1);
|
||||||
|
component.move(0, 0, 1);
|
||||||
|
component.add(0, 3);
|
||||||
|
component.remove(1, 0);
|
||||||
|
expect(component.assignments).toEqual([[1, 3], [2]]);
|
||||||
|
expect(show.dayParts[0].danceGroupIds).toEqual([1, 2]);
|
||||||
|
expect(component.dirty()).toBe(true);
|
||||||
|
});
|
||||||
|
it('saves invalid drafts and displays server violations without finalizing', async () => {
|
||||||
|
const component = editor();
|
||||||
|
component.remove(0, 0);
|
||||||
|
const draft = { ...show, finalized: false, violations: ['Group 1 requires 1 dayparts.'] };
|
||||||
|
api.saveAssignments.mockReturnValue(of(draft));
|
||||||
|
await component.save();
|
||||||
|
expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]]);
|
||||||
|
expect(component.current()).toEqual(draft);
|
||||||
|
expect(component.dirty()).toBe(false);
|
||||||
|
expect(api.finalize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('keeps unsaved edits when saving fails', async () => {
|
||||||
|
const component = editor();
|
||||||
|
component.remove(0, 0);
|
||||||
|
api.saveAssignments.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
|
||||||
|
await component.save();
|
||||||
|
expect(component.assignments).toEqual([[2], [3]]);
|
||||||
|
expect(component.dirty()).toBe(true);
|
||||||
|
expect(component.error()).toContain('niet bereikbaar');
|
||||||
|
expect(component.busy()).toBe(false);
|
||||||
|
});
|
||||||
|
it('finalizes through the server and reports a stale validation failure', async () => {
|
||||||
|
const component = editor();
|
||||||
|
api.finalize.mockReturnValue(of(show));
|
||||||
|
await component.finalize();
|
||||||
|
expect(component.current()?.finalized).toBe(true);
|
||||||
|
api.finalize.mockReturnValue(
|
||||||
|
throwError(
|
||||||
|
() => new HttpErrorResponse({ status: 400, error: { message: 'Changing gap violated' } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await component.finalize();
|
||||||
|
expect(component.error()).toContain('Changing gap violated');
|
||||||
|
});
|
||||||
|
});
|
||||||
178
agiledancers-fe/src/app/shared/schedule-editor.ts
Normal file
178
agiledancers-fe/src/app/shared/schedule-editor.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { Component, inject, input, OnInit, output, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { DanceGroup, Show } from '../core/api-models';
|
||||||
|
import { SchoolApi } from '../core/school-api';
|
||||||
|
import { apiError } from '../core/api-error';
|
||||||
|
import { DialogFrame } from './dialog-frame';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-schedule-editor',
|
||||||
|
imports: [FormsModule, DialogFrame],
|
||||||
|
template: `
|
||||||
|
<app-dialog-frame
|
||||||
|
[title]="show().name + ' — indeling'"
|
||||||
|
[busy]="busy()"
|
||||||
|
(dismissed)="closed.emit()"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{{ !dirty() && current()?.finalized ? 'Definitief' : 'Concept' }} · x =
|
||||||
|
{{ show().preferredDayParts }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Tussen twee dansen van dezelfde danser zijn twee andere optredens nodig. Je kunt een
|
||||||
|
onvolledige indeling als concept opslaan.
|
||||||
|
</p>
|
||||||
|
@if (error()) {
|
||||||
|
<p role="alert" class="alert error">{{ error() }}</p>
|
||||||
|
}
|
||||||
|
@if (dirty()) {
|
||||||
|
<p role="status">
|
||||||
|
Niet-opgeslagen wijzigingen. Sla het concept op om alle regels te controleren.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@for (violation of current()?.violations ?? []; track $index) {
|
||||||
|
<p class="alert error">{{ violation }}</p>
|
||||||
|
}
|
||||||
|
<fieldset [disabled]="busy()">
|
||||||
|
@for (part of show().dayParts; track $index; let p = $index) {
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
{{ part.date }} · {{ part.type }} ({{ assignments[p].length }} optredens)
|
||||||
|
</legend>
|
||||||
|
<ol>
|
||||||
|
@for (id of assignments[p]; track $index; let i = $index) {
|
||||||
|
<li>
|
||||||
|
{{ name(id) }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[disabled]="i === 0"
|
||||||
|
(click)="reorder(p, i, -1)"
|
||||||
|
[attr.aria-label]="name(id) + ' omhoog'"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[disabled]="i === assignments[p].length - 1"
|
||||||
|
(click)="reorder(p, i, 1)"
|
||||||
|
[attr.aria-label]="name(id) + ' omlaag'"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<label
|
||||||
|
>Verplaatsen naar
|
||||||
|
<select [ngModel]="p" (ngModelChange)="move(p, i, +$event)">
|
||||||
|
@for (target of show().dayParts; track $index; let t = $index) {
|
||||||
|
<option [ngValue]="t">{{ target.date }} · {{ target.type }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" (click)="remove(p, i)">Optreden verwijderen</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
<label
|
||||||
|
>Groep toevoegen
|
||||||
|
<select [ngModel]="null" (ngModelChange)="add(p, +$event)">
|
||||||
|
<option [ngValue]="null">Kies een groep</option>
|
||||||
|
@for (group of groups(); track group.id) {
|
||||||
|
@if (group.dancerIds.length && !assignments[p].includes(group.id)) {
|
||||||
|
<option [ngValue]="group.id">{{ group.category }}</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="button secondary" [disabled]="busy()" (click)="closed.emit()">
|
||||||
|
Gereed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button primary"
|
||||||
|
[disabled]="busy() || !dirty()"
|
||||||
|
(click)="save()"
|
||||||
|
>
|
||||||
|
Concept opslaan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button primary"
|
||||||
|
[disabled]="busy() || dirty() || current()?.finalized || !!current()?.violations?.length"
|
||||||
|
(click)="finalize()"
|
||||||
|
>
|
||||||
|
Definitief maken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</app-dialog-frame>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ScheduleEditor implements OnInit {
|
||||||
|
readonly show = input.required<Show>();
|
||||||
|
readonly groups = input.required<DanceGroup[]>();
|
||||||
|
readonly schoolId = input.required<number>();
|
||||||
|
readonly closed = output<void>();
|
||||||
|
readonly saved = output<void>();
|
||||||
|
readonly busy = signal(false);
|
||||||
|
readonly dirty = signal(false);
|
||||||
|
readonly error = signal('');
|
||||||
|
readonly current = signal<Show | null>(null);
|
||||||
|
private readonly api = inject(SchoolApi);
|
||||||
|
assignments: number[][] = [];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.current.set(this.show());
|
||||||
|
this.assignments = this.show().dayParts.map((p) => [...p.danceGroupIds]);
|
||||||
|
}
|
||||||
|
name(id: number): string {
|
||||||
|
return this.groups().find((g) => g.id === id)?.category ?? `Groep ${id}`;
|
||||||
|
}
|
||||||
|
reorder(p: number, i: number, delta: number): void {
|
||||||
|
const list = this.assignments[p];
|
||||||
|
[list[i], list[i + delta]] = [list[i + delta], list[i]];
|
||||||
|
this.dirty.set(true);
|
||||||
|
}
|
||||||
|
move(p: number, i: number, target: number): void {
|
||||||
|
if (target === p || this.assignments[target].includes(this.assignments[p][i])) return;
|
||||||
|
this.assignments[target].push(this.assignments[p].splice(i, 1)[0]);
|
||||||
|
this.dirty.set(true);
|
||||||
|
}
|
||||||
|
add(p: number, id: number): void {
|
||||||
|
if (!id || this.assignments[p].includes(id)) return;
|
||||||
|
this.assignments[p].push(id);
|
||||||
|
this.dirty.set(true);
|
||||||
|
}
|
||||||
|
remove(p: number, i: number): void {
|
||||||
|
this.assignments[p].splice(i, 1);
|
||||||
|
this.dirty.set(true);
|
||||||
|
}
|
||||||
|
async save(): Promise<void> {
|
||||||
|
await this.perform(false);
|
||||||
|
}
|
||||||
|
async finalize(): Promise<void> {
|
||||||
|
await this.perform(true);
|
||||||
|
}
|
||||||
|
private async perform(finalize: boolean): Promise<void> {
|
||||||
|
if (this.busy()) return;
|
||||||
|
this.busy.set(true);
|
||||||
|
this.error.set('');
|
||||||
|
try {
|
||||||
|
const result = await firstValueFrom(
|
||||||
|
finalize
|
||||||
|
? this.api.finalize(this.schoolId(), this.show().id)
|
||||||
|
: this.api.saveAssignments(this.schoolId(), this.show().id, this.assignments),
|
||||||
|
);
|
||||||
|
this.current.set(result);
|
||||||
|
this.assignments = result.dayParts.map((p) => [...p.danceGroupIds]);
|
||||||
|
this.dirty.set(false);
|
||||||
|
this.saved.emit();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.error.set(apiError(error));
|
||||||
|
} finally {
|
||||||
|
this.busy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
docs/s18-validation.md
Normal file
70
docs/s18-validation.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# S18 implementation validation
|
||||||
|
|
||||||
|
Validated on 2026-11-03 against the confirmed S18 specification.
|
||||||
|
|
||||||
|
## Automated checks
|
||||||
|
|
||||||
|
- JDK 25 `mvn clean verify`: passed, 52 backend tests and 19 standards-tool
|
||||||
|
regression tests, with no failures or skipped tests.
|
||||||
|
- Clean backend line coverage: 1175/1215 lines, 96.70781893004116%, above the
|
||||||
|
exact 80% gate. No handwritten-code exclusions were introduced.
|
||||||
|
- Frontend `npm test`: 24 tests across six files passed.
|
||||||
|
- Frontend production build and ESLint: passed.
|
||||||
|
- Playwright with the real REST backend: all three scenarios passed, covering school management,
|
||||||
|
shared memberships, automatic scheduling, manual draft editing and finalization,
|
||||||
|
a coach accompanying preschoolers across dates, and connection-error recovery.
|
||||||
|
|
||||||
|
Focused solver tests cover showcase bookends (including extra showcases), single
|
||||||
|
and absent showcases, changing gaps, coach exemptions, infeasibility, the strict
|
||||||
|
priority of showcase attendance over balance, and style/age scores. Lifecycle
|
||||||
|
and HTTP tests cover skipped empty groups, first-show-date ages, retained x after
|
||||||
|
daypart removal, atomic structural validation, invalid drafts, final status
|
||||||
|
invalidation, stale solver results, timeout preservation, coach identity links,
|
||||||
|
and ordered JSON round trips including version-2 migration.
|
||||||
|
|
||||||
|
## Manual standards assessment
|
||||||
|
|
||||||
|
The standards report passes without new waivers. Retained review signals were
|
||||||
|
assessed against `CODING_STANDARDS.md` and `docs/backend-review.md`:
|
||||||
|
|
||||||
|
- `GroupRequirement`, `SchedulingInput`, `Performance` and `ShowView` carry six
|
||||||
|
or seven related snapshot/API values. Collections are defensively copied;
|
||||||
|
coach identity and group membership use IDs across the solver boundary.
|
||||||
|
The larger record shapes make the scheduling contract explicit. Other added
|
||||||
|
records are small. `OrderedAssignments` deliberately owns mutable OR-Tools
|
||||||
|
variables and arrays internally during one solve; it is not an API snapshot.
|
||||||
|
- `ScheduleRules` has cyclomatic signals for independent configuration checks
|
||||||
|
and showcase cases. Each rule family is isolated in a short method and shared
|
||||||
|
between finalization and solver-result validation.
|
||||||
|
- `ShowSchedules` retains branching for nullable initial settings, retained x,
|
||||||
|
and structural draft validation. It stages complete replacement lists before
|
||||||
|
mutation and uses the existing aggregate lock. It separates schedule lifecycle
|
||||||
|
from the already broad `SchoolManagementService` aggregate owner.
|
||||||
|
- The aggregate owner's fan-out and public-method signals are retained for its
|
||||||
|
repository and transaction ownership. Scheduling-input preparation, coach
|
||||||
|
identity operations, lifecycle rules and scoring reside in separate focused
|
||||||
|
classes. Its new revision operation invalidates final status as well as pending
|
||||||
|
solver results.
|
||||||
|
- `SchoolSnapshot` retains orchestration size, fan-out and cognitive signals for
|
||||||
|
validating and reconstructing an entire graph. Import resolves coach/dancer
|
||||||
|
links explicitly, retains order, rejects unknown references, accepts invalid
|
||||||
|
assignment drafts and resets imported final status. Graph construction remains
|
||||||
|
staged before repository mutation.
|
||||||
|
- REST resource fan-out is mainly endpoint annotations, request/view types and
|
||||||
|
domain/service collaborators. OR-Tools stays inside the solver package.
|
||||||
|
`SchedulingInputs` uses domain types to produce one immutable solver snapshot.
|
||||||
|
- `OrToolsSchedulingEngine` retains a length/cognitive signal for the ordered
|
||||||
|
optimization stages and their feasible/optimal/timeout outcomes. Keeping the
|
||||||
|
incumbent and freezing only proven optima preserves strict objective priority
|
||||||
|
and valid results within one total deadline.
|
||||||
|
- Solver objective helpers retain five-parameter review signals where a model,
|
||||||
|
variables, group pair and objective coefficient are needed. Replacing those
|
||||||
|
with additional transient wrapper types would obscure the constraint mapping.
|
||||||
|
Common adjacency penalties are algebraically removed without changing scores.
|
||||||
|
- Test fan-out and scenario length signals reflect full HTTP/import/concurrency
|
||||||
|
workflows. Shared fixture setup is extracted where it aids readability; every
|
||||||
|
executable remains within the blocking limits.
|
||||||
|
|
||||||
|
Public behavior is documented in `show-scheduling-rules.md`, the README and the
|
||||||
|
school JSON format reference. No new standards waiver, threshold change or
|
||||||
|
analysis exclusion is required.
|
||||||
89
show-scheduling-rules.md
Normal file
89
show-scheduling-rules.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Show scheduling rules
|
||||||
|
|
||||||
|
A show is the complete event, including every dated daypart. It may span several
|
||||||
|
calendar dates. Counts do not restart on a new date.
|
||||||
|
|
||||||
|
## Required performances
|
||||||
|
|
||||||
|
The manager chooses **x**, the desired number of distinct dayparts, per show.
|
||||||
|
It defaults to two, or one for a show with only one daypart. A show can be saved
|
||||||
|
without dayparts but cannot be scheduled. If dayparts are removed, x is retained
|
||||||
|
and must be corrected before scheduling when it exceeds the remaining count.
|
||||||
|
|
||||||
|
Every nonempty regular, selection and pre-education group performs in exactly x
|
||||||
|
dayparts, once in each. Each preschool group performs exactly once in the whole
|
||||||
|
show. Every showcase performs once in every daypart. Groups without dancers are
|
||||||
|
skipped completely, including for showcase positions and balancing.
|
||||||
|
|
||||||
|
With at least two showcases, one opens and another closes every daypart. Any
|
||||||
|
additional showcases may appear between them. The opening and closing groups can
|
||||||
|
change between dayparts. A single showcase may open or close, but never performs
|
||||||
|
twice in the same daypart. Without showcases there is no opening/closing rule.
|
||||||
|
|
||||||
|
## People and changing clothes
|
||||||
|
|
||||||
|
An ordinary dancer must perform in no more than x distinct dayparts across all
|
||||||
|
of their groups. The same dancer registration must be used in every group.
|
||||||
|
|
||||||
|
Showcase participants necessarily perform in every daypart. For their other
|
||||||
|
performances, exceeding x dayparts is allowed but discouraged. We count their
|
||||||
|
non-showcase dayparts, subtract x, and count only positive excesses. The planner's
|
||||||
|
first preference is to minimize the sum of these excesses across dancers.
|
||||||
|
|
||||||
|
A coach registration must be explicitly linked to the corresponding dancer;
|
||||||
|
names are not used to guess identity. Coaches are available throughout the show
|
||||||
|
and are exempt from both the x limit and the excess penalty. A coach only performs
|
||||||
|
with a group if included in its dancer membership. Being a group's coach alone
|
||||||
|
never adds a performance or forces that group into extra dayparts.
|
||||||
|
|
||||||
|
Between any two performances involving the same person, at least two other
|
||||||
|
performances must intervene in which that person does not dance. This includes
|
||||||
|
coaches accompanying preschoolers, coaches dancing in other groups, and showcases.
|
||||||
|
There are no exceptions. Between dayparts we assume sufficient changing time,
|
||||||
|
so the gap rule restarts even if the dayparts are on the same date.
|
||||||
|
|
||||||
|
## Balance and preferences
|
||||||
|
|
||||||
|
All performances count equally, including preschool groups and showcases. The
|
||||||
|
busiest daypart may have at most two more performances than the quietest. Zero
|
||||||
|
is ideal. Preschool groups can cause a difference but have no special exemption.
|
||||||
|
|
||||||
|
Among schedules satisfying every mandatory rule, optimize in this strict order:
|
||||||
|
|
||||||
|
1. Minimize non-showcase daypart excess for showcase participants, excluding coaches.
|
||||||
|
2. Minimize the difference in performance counts between dayparts.
|
||||||
|
3. Minimize similarity of consecutive performances within each daypart.
|
||||||
|
|
||||||
|
A lower priority cannot worsen a higher priority. OR-Tools solves these objectives
|
||||||
|
in sequence, fixing each proven optimum before working on the next. If it finds
|
||||||
|
only a feasible result at a priority, it returns that valid result without
|
||||||
|
claiming that the complete schedule is optimal.
|
||||||
|
|
||||||
|
For variety, style similarity is the fraction of shared styles among all distinct
|
||||||
|
styles of the two groups. Two groups without styles have no style penalty.
|
||||||
|
Age similarity is one for equal average ages, falling linearly to zero at a
|
||||||
|
five-year difference and remaining zero above that difference. Style and age
|
||||||
|
have equal weight. Each component is rounded to millionths for CP-SAT's integer
|
||||||
|
objective. Showcases count in this comparison. Average age includes every dancing
|
||||||
|
member equally, including coaches, measured in completed years on the first date
|
||||||
|
of the show. Empty groups are already excluded.
|
||||||
|
|
||||||
|
## Results, drafts and final schedules
|
||||||
|
|
||||||
|
Scheduling can take up to thirty minutes in total across the optimization stages.
|
||||||
|
A feasible result is accepted even when optimality is unproven. Every mandatory
|
||||||
|
rule still applies. Proven infeasibility and reaching the time limit without a
|
||||||
|
result are reported differently. Failures preserve the saved assignments and
|
||||||
|
order. Concurrent runs are rejected and changed inputs invalidate pending results.
|
||||||
|
|
||||||
|
Automatic results are drafts for manager review. Managers can also move, add,
|
||||||
|
remove and reorder performances manually and save incomplete or invalid drafts.
|
||||||
|
The application lists the rules those drafts violate. An explicit finalization
|
||||||
|
action checks all mandatory rules; only a valid schedule can become final.
|
||||||
|
Preferences alone never block finalization.
|
||||||
|
|
||||||
|
Manual edits and relevant membership, dancer-age, coach-link or show-setting
|
||||||
|
changes return final schedules to draft. Existing schedules and imported schedules
|
||||||
|
are drafts. Import retains assignment order and recalculates violations. Export
|
||||||
|
is available for both statuses and records status, x and coach identity links;
|
||||||
|
reimport always requires a fresh finalization.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# School JSON format, version 1
|
# School JSON format, version 3
|
||||||
|
|
||||||
Export: `GET /api/dance-schools/{id}/export` downloads readable JSON.
|
Export: `GET /api/dance-schools/{id}/export` downloads readable JSON.
|
||||||
Preview: `POST /api/dance-schools/import/preview` accepts the file itself as
|
Preview: `POST /api/dance-schools/import/preview` accepts the file itself as
|
||||||
@@ -11,19 +11,19 @@ There is no merge endpoint.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 3,
|
||||||
"key": "1b8fe22c-5ac3-4ab9-a2c8-d1d28d2becee",
|
"key": "1b8fe22c-5ac3-4ab9-a2c8-d1d28d2becee",
|
||||||
"name": "Studio",
|
"name": "Studio",
|
||||||
"dancers": [{ "id": "d1", "name": "Sam", "birthDate": "2010-05-12" }],
|
"dancers": [{ "id": "d1", "name": "Sam", "birthDate": "2010-05-12" }],
|
||||||
"coaches": [{ "id": "c1", "name": "Alex" }],
|
"coaches": [{ "id": "c1", "name": "Alex", "dancerId": "d1" }],
|
||||||
"groups": [{
|
"groups": [{
|
||||||
"id": "g1", "category": "Hiphop", "type": "REGULAR",
|
"id": "g1", "category": "Hiphop", "type": "REGULAR",
|
||||||
"startAge": 12, "endAge": 18, "styles": ["HIPHOP"],
|
"startAge": 12, "endAge": 18, "styles": ["HIPHOP"],
|
||||||
"dancerIds": ["d1"], "coachId": "c1"
|
"dancerIds": ["d1"], "coachId": "c1"
|
||||||
}],
|
}],
|
||||||
"shows": [{
|
"shows": [{
|
||||||
"id": "s1", "name": "Summer show", "date": "2027-06-12",
|
"id": "s1", "name": "Summer show", "preferredDayParts": 1, "finalized": false,
|
||||||
"dayParts": [{ "type": "MORNING", "groupIds": ["g1"] }]
|
"dayParts": [{ "date": "2027-06-12", "type": "MORNING", "groupIds": ["g1"] }]
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -32,14 +32,20 @@ There is no merge endpoint.
|
|||||||
inside each collection are nonblank strings, unique within that collection, and
|
inside each collection are nonblank strings, unique within that collection, and
|
||||||
local to this file. They can change on export. References must resolve within the
|
local to this file. They can change on export. References must resolve within the
|
||||||
file. A dancer or coach can be referenced by multiple groups. A group can appear
|
file. A dancer or coach can be referenced by multiple groups. A group can appear
|
||||||
in multiple day parts. A day-part type can occur once per show.
|
in multiple day parts. A date/type combination can occur once per show, with at most six dayparts.
|
||||||
|
|
||||||
All fields are required except `coachId`, which may be absent or null. Collections
|
The optional `coachId` and coach `dancerId` references may be absent or null.
|
||||||
|
`preferredDayParts` defaults to two (one for a single daypart); `finalized`
|
||||||
|
defaults to false. Legacy version 2 files remain supported; version 1 is rejected.
|
||||||
|
Legacy `appearances` counts may be present but do not govern S18 scheduling.
|
||||||
|
Every imported schedule becomes a draft, even when exported as final.
|
||||||
|
`preferredDayParts` must be an integer from one to six; a retained value exceeding
|
||||||
|
the daypart count must be corrected before scheduling or finalization. Collections
|
||||||
may be empty. Names/category must be nonblank. Dates use ISO `yyyy-MM-dd`;
|
may be empty. Names/category must be nonblank. Dates use ISO `yyyy-MM-dd`;
|
||||||
birth dates cannot be in the future. Ages are nonnegative integers with
|
birth dates cannot be in the future. Ages are nonnegative integers with
|
||||||
`startAge <= endAge`. Enum values use the same uppercase names as the management
|
`startAge <= endAge`. Enum values use the same uppercase names as the management
|
||||||
API. Day-part types are `MORNING`, `AFTERNOON`, `EVENING`.
|
API. Day-part types are `MORNING`, `AFTERNOON`, `EVENING`.
|
||||||
Unknown fields, duplicate JSON properties, duplicate IDs or references, invalid
|
Unknown fields, duplicate JSON properties, duplicate IDs or membership references, invalid
|
||||||
values and unsupported versions are rejected. Validation is repeated on commit.
|
values and unsupported versions are rejected. Validation is repeated on commit.
|
||||||
The entire school is prepared before publishing it under the shared service lock.
|
The entire school is prepared before publishing it under the shared service lock.
|
||||||
Replacing retains the numeric school ID for existing browser links; imported
|
Replacing retains the numeric school ID for existing browser links; imported
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# S18 — Scheduling dance groups across a show
|
||||||
|
|
||||||
|
Status: IN_REVIEW
|
||||||
|
|
||||||
|
As a dance school manager,
|
||||||
|
I want OR-Tools to schedule groups and order performances using participation,
|
||||||
|
changing-time and variety rules,
|
||||||
|
so that the complete show is manageable for dancers and enjoyable for audiences.
|
||||||
|
|
||||||
|
Refinement confirmed by the maintainer on 2026-11-03. This specification records
|
||||||
|
the agreed design. Implementation and validation are complete and ready for review.
|
||||||
|
See [validation evidence](../../docs/s18-validation.md) and
|
||||||
|
[the applied scheduling rules](../../show-scheduling-rules.md).
|
||||||
|
|
||||||
|
## Show scope and configuration
|
||||||
|
|
||||||
|
- A show consists of dayparts and can span multiple dates, typically two days.
|
||||||
|
All counts below apply across the entire show, not separately per date.
|
||||||
|
- Each show has one configurable integer `x`, between 1 and the number of
|
||||||
|
dayparts inclusive. Its default is 2, or 1 for a one-daypart show.
|
||||||
|
- This replaces the existing configurable appearance counts per group type.
|
||||||
|
- A show without dayparts can be stored but cannot be scheduled.
|
||||||
|
- If reducing the number of dayparts makes `x` invalid, require the user to
|
||||||
|
correct it before scheduling again; do not silently change it.
|
||||||
|
- Groups without dancers are excluded from scheduling and from all performance
|
||||||
|
counts and showcase-position requirements.
|
||||||
|
|
||||||
|
## Required group appearances
|
||||||
|
|
||||||
|
- Each nonempty REGULAR, SELECTION and PRE_EDUCATION group performs in exactly
|
||||||
|
`x` distinct dayparts, once within each selected daypart.
|
||||||
|
- Each nonempty PRE_SCHOOLER group performs exactly once in the complete show,
|
||||||
|
regardless of `x`. At most two such groups are expected. They can be assigned
|
||||||
|
freely to dayparts; no additional placement restriction applies.
|
||||||
|
- Each nonempty SHOW_CASE group performs exactly once in every daypart.
|
||||||
|
- With at least two showcase groups, each daypart starts with one showcase and
|
||||||
|
ends with another. Additional showcases can appear anywhere between them.
|
||||||
|
Opening and closing groups may differ between dayparts.
|
||||||
|
- With exactly one showcase group, it appears either first or last in each
|
||||||
|
daypart; the planner chooses. It does not perform twice in that daypart.
|
||||||
|
- With no showcase groups, there is no showcase-position requirement.
|
||||||
|
|
||||||
|
## Dancer participation and coaches
|
||||||
|
|
||||||
|
- A dancer without a showcase membership performs in at most `x` distinct
|
||||||
|
dayparts across the complete show. This is a hard constraint.
|
||||||
|
- A showcase participant necessarily performs in all dayparts. Their other
|
||||||
|
group performances may span more than `x` dayparts, but this is undesirable.
|
||||||
|
- For each non-coach showcase participant, count the distinct dayparts in which
|
||||||
|
they perform outside showcases. Their excess is `max(0, count - x)`.
|
||||||
|
Minimize the sum of these excesses as the first optimization priority.
|
||||||
|
- A person dancing in several groups must use the same dancer registration in
|
||||||
|
every group, including when accompanying preschoolers. Up to five group
|
||||||
|
memberships per dancer are expected.
|
||||||
|
- Explicitly link a coach registration to the corresponding dancer registration;
|
||||||
|
never infer identity from matching names. Support maintaining this link.
|
||||||
|
- Coaches are available in all dayparts and are excluded from both the hard
|
||||||
|
dancer limit and the excess penalty, including coaches in showcases.
|
||||||
|
- Being assigned as a group's coach does not automatically mean performing in
|
||||||
|
that group. Actual performances are determined by dancer membership only.
|
||||||
|
Coach availability never increases a group's required appearances.
|
||||||
|
- Dancing coaches remain subject to changing-time rules and contribute to the
|
||||||
|
group's average age just like other dancing members.
|
||||||
|
|
||||||
|
## Changing time and feasibility
|
||||||
|
|
||||||
|
- Between any two performances involving the same dancer within a daypart,
|
||||||
|
there must be at least two intervening performances not involving that dancer.
|
||||||
|
- This is mandatory for everyone, including coaches and showcase participants.
|
||||||
|
- The rule restarts at each daypart boundary. Assume enough changing time
|
||||||
|
between dayparts, including on the same date.
|
||||||
|
- All hard constraints apply together, even for unusual membership combinations
|
||||||
|
such as a person dancing in both preschool and ordinary groups. Do not relax
|
||||||
|
a rule to obtain a result.
|
||||||
|
|
||||||
|
## Balance and optimization priorities
|
||||||
|
|
||||||
|
- Count all scheduled performances, including preschool and showcase groups.
|
||||||
|
- The difference between the largest and smallest daypart performance counts
|
||||||
|
must not exceed two. This is a hard constraint.
|
||||||
|
- Prefer a difference of zero. Preschool groups are an expected cause of
|
||||||
|
differences but have no special exemption or exclusive right to cause them.
|
||||||
|
- Optimize valid schedules in this strict priority order:
|
||||||
|
1. Minimize the total excess for non-coach showcase participants defined above.
|
||||||
|
2. Minimize the difference between largest and smallest daypart counts.
|
||||||
|
3. Improve style and age variety between consecutive performances within
|
||||||
|
each daypart.
|
||||||
|
- A lower priority must not worsen a higher priority. Showcases participate in
|
||||||
|
variety scoring as well.
|
||||||
|
|
||||||
|
## Variety scoring
|
||||||
|
|
||||||
|
- For adjacent groups, the style penalty is the number of shared styles divided
|
||||||
|
by the number of distinct styles in their combined style sets. No shared
|
||||||
|
styles gives zero penalty; two empty style sets also give zero penalty.
|
||||||
|
- Compute dancer ages on the first date of the show. All dancers in a group,
|
||||||
|
including dancing coaches, contribute equally to its average age.
|
||||||
|
- The age penalty decreases linearly from 1 for equal average ages to 0 for a
|
||||||
|
difference of five years; differences greater than five years also score 0.
|
||||||
|
Equivalently: `max(0, 1 - abs(meanAgeA - meanAgeB) / 5)`.
|
||||||
|
- Style and age penalties have equal weight. Minimize their combined penalties
|
||||||
|
over adjacent performances. Empty groups have already been excluded.
|
||||||
|
|
||||||
|
## Solver execution and failure
|
||||||
|
|
||||||
|
- Retain a maximum solver runtime of thirty minutes.
|
||||||
|
- Accept a feasible result without proof of optimality, provided every hard
|
||||||
|
constraint holds. Distinguish feasibility from proven optimality.
|
||||||
|
- If no valid result is found, preserve the existing assignments and order.
|
||||||
|
- Explain whether infeasibility was proven or the time limit expired without
|
||||||
|
a valid result; do not report a timeout as proof of infeasibility.
|
||||||
|
- Retain existing protections against concurrent scheduling and applying stale
|
||||||
|
results after relevant data changes.
|
||||||
|
|
||||||
|
## Manual editing and schedule lifecycle
|
||||||
|
|
||||||
|
- Add UI and API support for moving performances between dayparts and changing
|
||||||
|
their order. Partial or temporarily invalid assignments can be saved as drafts.
|
||||||
|
- Distinguish draft and final schedules explicitly. Display concrete violations
|
||||||
|
for invalid drafts; do not present them as valid final schedules.
|
||||||
|
- Add an explicit finalization action that checks all hard rules and permits
|
||||||
|
finalization only when they all pass. Optimization preferences do not block it.
|
||||||
|
- Automatically generated valid schedules also start as drafts for manager review.
|
||||||
|
- Manual changes to a final schedule return it to draft. Changes to relevant
|
||||||
|
dancer memberships, coach identity links or show settings likewise invalidate
|
||||||
|
final status when they affect scheduling rules.
|
||||||
|
- Treat existing schedules as drafts when introducing this lifecycle.
|
||||||
|
- JSON import preserves assignments and order but makes imported schedules
|
||||||
|
drafts and validates them against the new rules.
|
||||||
|
- JSON export remains available for drafts and finals and preserves status.
|
||||||
|
Import still produces a draft even when the exported status was final.
|
||||||
|
- Include the new configuration and coach identity information in persistence
|
||||||
|
and school JSON round trips so scheduling semantics survive saving and import.
|
||||||
|
|
||||||
|
## Documentation and validation
|
||||||
|
|
||||||
|
- Provide `show-scheduling-rules.md` describing the rules applied by OR-Tools in
|
||||||
|
plain English, including hard constraints, exceptions, optimization priorities,
|
||||||
|
scoring, cross-date scope and failure behavior.
|
||||||
|
- Validate automatic results and finalization using the same agreed rules.
|
||||||
|
- Verification must cover ordinary, preschool and showcase appearances; coach
|
||||||
|
identity and exemptions; overlapping memberships; changing gaps; balance;
|
||||||
|
optimization priority and scoring; multi-date shows; empty groups; invalid
|
||||||
|
configuration; infeasible and timed-out runs; manual drafts/finalization; and
|
||||||
|
persistence/import/export of the new information.
|
||||||
Reference in New Issue
Block a user