All checks were successful
Build, test and publish / verify (push) Successful in 7m31s
458 lines
20 KiB
Java
458 lines
20 KiB
Java
package nl.agilecc.agiledancers.service;
|
|
|
|
import jakarta.inject.Singleton;
|
|
import java.time.LocalDate;
|
|
import java.util.ArrayList;
|
|
import java.util.Collection;
|
|
import java.util.HashMap;
|
|
import java.util.HashSet;
|
|
import java.util.IdentityHashMap;
|
|
import java.util.LinkedHashSet;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Objects;
|
|
import java.util.Set;
|
|
import nl.agilecc.agiledancers.model.AbstractEntity;
|
|
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.ScheduleRules;
|
|
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.repository.DanceGroupRepository;
|
|
import nl.agilecc.agiledancers.repository.DanceSchoolRepository;
|
|
import nl.agilecc.agiledancers.repository.DancerRepository;
|
|
import nl.agilecc.agiledancers.repository.ShowRepository;
|
|
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
|
import nl.agilecc.agiledancers.service.model.ImportPreview;
|
|
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
|
|
|
/** Owns detached school aggregates within the shared transaction monitor; all domain writes pass through here. */
|
|
@Singleton
|
|
public class SchoolManagementService {
|
|
private final DanceSchoolRepository schools = new DanceSchoolRepository();
|
|
private final DancerRepository dancers = new DancerRepository();
|
|
private final DanceGroupRepository groups = new DanceGroupRepository();
|
|
private final ShowRepository shows = new ShowRepository();
|
|
|
|
private final Map<Long, Long> revisions = new HashMap<>();
|
|
private final Set<Long> running = new HashSet<>();
|
|
|
|
/** Installs a detached relational snapshot under the service's existing aggregate lock. */
|
|
public synchronized void load(final List<DanceSchool> state, final java.util.function.LongSupplier identifiers) {
|
|
revisions.clear();
|
|
schools.load(state, identifiers);
|
|
dancers.load(
|
|
state.stream()
|
|
.flatMap((final DanceSchool school) -> school.getDancers().stream())
|
|
.toList(),
|
|
identifiers);
|
|
groups.load(
|
|
state.stream()
|
|
.flatMap((final DanceSchool school) -> school.getDanceGroups().stream())
|
|
.toList(),
|
|
identifiers);
|
|
shows.load(
|
|
state.stream()
|
|
.flatMap((final DanceSchool school) -> school.getShows().stream())
|
|
.toList(),
|
|
identifiers);
|
|
}
|
|
|
|
public synchronized void touch(final Show show) {
|
|
show.setFinalized(false);
|
|
revisions.merge(show.getId(), 1L, Long::sum);
|
|
}
|
|
|
|
/** Reserves this show's run slot and snapshots its inputs; always release it with endScheduling. */
|
|
public synchronized SchedulingInput beginScheduling(final long schoolId, final long showId) {
|
|
final Show show = show(schoolId, showId);
|
|
if (running.contains(showId)) {
|
|
throw new ManagementException(
|
|
ManagementExceptionKind.CONFLICT, "Scheduling is already running for this show.");
|
|
}
|
|
final List<DanceGroup> current = groups(schoolId);
|
|
if (current.isEmpty() || show.getDayParts().isEmpty()) {
|
|
throw new ManagementException(
|
|
ManagementExceptionKind.INVALID_INPUT,
|
|
"Scheduling requires at least one school group and one daypart.");
|
|
}
|
|
if (SchedulingInputs.preferred(show) > show.getDayParts().size()) {
|
|
throw new ManagementException(
|
|
ManagementExceptionKind.INVALID_INPUT, "Correct preferredDayParts before scheduling.");
|
|
}
|
|
running.add(showId);
|
|
return SchedulingInputs.of(
|
|
school(schoolId), show, school(schoolId).getRevision() + revisions.getOrDefault(showId, 0L));
|
|
}
|
|
|
|
/** Validates the complete result and input revision before replacing any saved assignments. */
|
|
public synchronized void finishScheduling(
|
|
final long schoolId, final SchedulingInput input, final SchedulingResult result) {
|
|
final Show show;
|
|
try {
|
|
show = show(schoolId, input.showId());
|
|
} catch (final ManagementException e) {
|
|
throw new ManagementException(
|
|
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
|
}
|
|
if (school(schoolId).getRevision() + revisions.getOrDefault(input.showId(), 0L) != input.revision()) {
|
|
throw new ManagementException(
|
|
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
|
}
|
|
final Map<Long, DanceGroup> current = validateAssignments(schoolId, input, result);
|
|
final List<DayPart> replacement = new ArrayList<>();
|
|
for (int p = 0; p < input.dayParts(); p++) {
|
|
final DayPart old = show.getDayParts().get(p);
|
|
replacement.add(DayPart.builder()
|
|
.date(old.getDate())
|
|
.type(old.getType())
|
|
.danceGroups(result.assignments().get(p).stream()
|
|
.map(current::get)
|
|
.toList())
|
|
.build());
|
|
}
|
|
show.setDayParts(List.copyOf(replacement));
|
|
touch(show);
|
|
}
|
|
|
|
private Map<Long, DanceGroup> validateAssignments(
|
|
final long schoolId, final SchedulingInput input, final SchedulingResult result) {
|
|
// Validate the complete solver output before mutating any assignments.
|
|
if (result == null
|
|
|| result.assignments() == null
|
|
|| result.assignments().size() != input.dayParts()) {
|
|
throw new ManagementException(
|
|
ManagementExceptionKind.INVALID_INPUT,
|
|
"Solver returned an invalid result. Existing assignments were preserved.");
|
|
}
|
|
final Map<Long, DanceGroup> current = new HashMap<>();
|
|
groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g));
|
|
final List<String> violations = ScheduleRules.violations(input, result.assignments());
|
|
if (!violations.isEmpty()) {
|
|
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", violations));
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/** Releases the run slot after success or failure, without changing saved assignments. */
|
|
public synchronized void endScheduling(final long showId) {
|
|
running.remove(showId);
|
|
}
|
|
|
|
public synchronized List<DanceSchool> schools() {
|
|
return schools.findAll();
|
|
}
|
|
|
|
public synchronized DanceSchool school(final long schoolId) {
|
|
return required(schools.findById(schoolId), "Dance school", schoolId);
|
|
}
|
|
|
|
public synchronized DanceSchool createSchool(final String name) {
|
|
return schools.add(DanceSchool.builder().name(name).build());
|
|
}
|
|
|
|
public synchronized DanceSchool updateSchool(final long schoolId, final String name) {
|
|
final DanceSchool school = school(schoolId);
|
|
school.setName(name);
|
|
return school;
|
|
}
|
|
|
|
public synchronized void deleteSchool(final long schoolId) {
|
|
final DanceSchool school = school(schoolId);
|
|
school.getDanceGroups().forEach((final DanceGroup group) -> groups.delete(group.getId()));
|
|
school.getDancers().forEach((final Dancer dancer) -> dancers.delete(dancer.getId()));
|
|
school.getShows().forEach((final Show show) -> shows.delete(show.getId()));
|
|
schools.delete(schoolId);
|
|
}
|
|
|
|
public synchronized List<Dancer> dancers(final long schoolId) {
|
|
return List.copyOf(school(schoolId).getDancers());
|
|
}
|
|
|
|
public synchronized Dancer dancer(final long schoolId, final long dancerId) {
|
|
return member(school(schoolId).getDancers(), dancerId, "Dancer");
|
|
}
|
|
|
|
public synchronized Dancer createDancer(final long schoolId, final String name, final LocalDate birthDate) {
|
|
final DanceSchool school = school(schoolId);
|
|
final Dancer dancer =
|
|
dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
|
|
school.addDancer(dancer);
|
|
return dancer;
|
|
}
|
|
|
|
public synchronized Dancer updateDancer(
|
|
final long schoolId, final long dancerId, final String name, final LocalDate birthDate) {
|
|
final Dancer dancer = dancer(schoolId, dancerId);
|
|
dancer.setName(name);
|
|
dancer.setBirthDate(birthDate);
|
|
school(schoolId).getShows().forEach(this::touch);
|
|
return dancer;
|
|
}
|
|
|
|
public synchronized void deleteDancer(final long schoolId, final long dancerId) {
|
|
final DanceSchool school = school(schoolId);
|
|
final Dancer dancer = dancer(schoolId, dancerId);
|
|
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);
|
|
dancers.delete(dancerId);
|
|
}
|
|
|
|
public synchronized List<DanceGroup> groups(final long schoolId) {
|
|
return List.copyOf(school(schoolId).getDanceGroups());
|
|
}
|
|
|
|
public synchronized DanceGroup group(final long schoolId, final long groupId) {
|
|
return member(school(schoolId).getDanceGroups(), groupId, "Dance group");
|
|
}
|
|
|
|
public synchronized DanceGroup createGroup(final long schoolId, final GroupDetails details) {
|
|
final DanceSchool school = school(schoolId);
|
|
validateAgeRange(details.startAge(), details.endAge());
|
|
final DanceGroup group = groups.add(DanceGroup.builder()
|
|
.category(details.category())
|
|
.type(details.type())
|
|
.startAge(details.startAge())
|
|
.endAge(details.endAge())
|
|
.styles(new LinkedHashSet<>(details.styles()))
|
|
.build());
|
|
school.addDanceGroup(group);
|
|
school.getShows().forEach(this::touch);
|
|
return group;
|
|
}
|
|
|
|
public synchronized DanceGroup updateGroup(final long schoolId, final long groupId, final GroupDetails details) {
|
|
final DanceGroup group = group(schoolId, groupId);
|
|
validateAgeRange(details.startAge(), details.endAge());
|
|
group.setCategory(details.category());
|
|
if (group.getType() != details.type() || !group.getStyles().equals(new HashSet<>(details.styles()))) {
|
|
school(schoolId).getShows().forEach(this::touch);
|
|
}
|
|
group.setType(details.type());
|
|
group.setStartAge(details.startAge());
|
|
group.setEndAge(details.endAge());
|
|
group.replaceStyles(details.styles());
|
|
return group;
|
|
}
|
|
|
|
public synchronized void deleteGroup(final long schoolId, final long groupId) {
|
|
final DanceGroup group = group(schoolId, groupId);
|
|
school(schoolId)
|
|
.getShows()
|
|
.forEach((final Show show) ->
|
|
show.getDayParts().forEach((final DayPart part) -> part.removeDanceGroup(group)));
|
|
school(schoolId).getShows().forEach(this::touch);
|
|
school(schoolId).removeDanceGroup(group);
|
|
groups.delete(groupId);
|
|
}
|
|
|
|
public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
|
final DanceGroup group = group(schoolId, groupId);
|
|
group.addDancer(dancer(schoolId, dancerId));
|
|
school(schoolId).getShows().forEach(this::touch);
|
|
}
|
|
|
|
public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
|
final DanceGroup group = group(schoolId, groupId);
|
|
group.removeDancer(dancer(schoolId, dancerId));
|
|
school(schoolId).getShows().forEach(this::touch);
|
|
}
|
|
|
|
public synchronized List<Show> shows(final long schoolId) {
|
|
return List.copyOf(school(schoolId).getShows());
|
|
}
|
|
|
|
public synchronized Show show(final long schoolId, final long showId) {
|
|
return member(school(schoolId).getShows(), showId, "Show");
|
|
}
|
|
|
|
public synchronized Show createShow(
|
|
final long schoolId, final String name, final List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
|
final DanceSchool school = school(schoolId);
|
|
if (counts == null) {
|
|
counts = ShowConfiguration.defaults(parts.size());
|
|
}
|
|
ShowConfiguration.validate(parts, counts);
|
|
final Show show = shows.add(Show.builder()
|
|
.name(name)
|
|
.dayParts(List.copyOf(parts))
|
|
.preferredDayParts((parts.size() == 1 ? 1 : 2))
|
|
.appearances(Map.copyOf(counts))
|
|
.build());
|
|
school.addShow(show);
|
|
return show;
|
|
}
|
|
|
|
public synchronized Show updateShow(
|
|
final long schoolId,
|
|
final long showId,
|
|
final String name,
|
|
final List<DayPart> parts,
|
|
Map<DanceGroupType, Integer> counts) {
|
|
final Show show = show(schoolId, showId);
|
|
if (counts == null) {
|
|
counts = show.getAppearances().isEmpty() ? ShowConfiguration.defaults(parts.size()) : show.getAppearances();
|
|
}
|
|
ShowConfiguration.validate(parts, counts);
|
|
final boolean changed =
|
|
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
|
final List<DayPart> retained = ShowConfiguration.retainAssignments(show, parts);
|
|
show.setName(name);
|
|
show.setDayParts(retained);
|
|
show.setAppearances(Map.copyOf(counts));
|
|
if (changed) {
|
|
touch(show);
|
|
}
|
|
return show;
|
|
}
|
|
|
|
private static List<String> partKeys(final List<DayPart> parts) {
|
|
return parts.stream()
|
|
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
|
|
.toList();
|
|
}
|
|
|
|
public synchronized void deleteShow(final long schoolId, final long showId) {
|
|
final Show show = show(schoolId, showId);
|
|
school(schoolId).removeShow(show);
|
|
shows.delete(showId);
|
|
}
|
|
|
|
public synchronized SchoolSnapshot exportSchool(final long id) {
|
|
return SchoolSnapshot.of(school(id));
|
|
}
|
|
|
|
public synchronized ImportPreview preview(final SchoolSnapshot snapshot) {
|
|
snapshot.restore();
|
|
final DanceSchool existing = schools.findAll().stream()
|
|
.filter((final DanceSchool s) -> s.getKey().equals(snapshot.key()))
|
|
.findFirst()
|
|
.orElse(null);
|
|
return new ImportPreview(
|
|
snapshot.name(),
|
|
snapshot.dancers().size(),
|
|
snapshot.groups().size(),
|
|
snapshot.shows().size(),
|
|
existing == null ? null : existing.getId(),
|
|
existing == null ? null : existing.getName(),
|
|
existing == null ? null : existing.getRevision());
|
|
}
|
|
|
|
/** Validates and stages the complete graph before replacing repositories; file IDs become runtime IDs. */
|
|
public synchronized DanceSchool importSchool(final SchoolSnapshot snapshot, final Long replaceId) {
|
|
final DanceSchool staged = snapshot.restore();
|
|
final long revision = replaceId == null ? 0 : school(replaceId).getRevision();
|
|
final ImportPreview preview = preview(snapshot);
|
|
if (!Objects.equals(preview.existingId(), replaceId)) {
|
|
throw new ManagementException(
|
|
ManagementExceptionKind.INVALID_INPUT,
|
|
"De bestaande school is gewijzigd. Bekijk de import opnieuw.");
|
|
}
|
|
// All validation and construction finish before changing repository state.
|
|
// Rebuild sets after assigning runtime IDs: entity hash codes include the ID.
|
|
final List<Dancer> importedDancers = new ArrayList<>(staged.getDancers());
|
|
final List<DanceGroup> importedGroups = new ArrayList<>(staged.getDanceGroups());
|
|
final List<Show> importedShows = new ArrayList<>(staged.getShows());
|
|
final Map<DanceGroup, List<Dancer>> memberships = new IdentityHashMap<>();
|
|
importedGroups.forEach((final DanceGroup g) -> memberships.put(g, new ArrayList<>(g.getDancers())));
|
|
importedDancers.forEach((final Dancer d) -> d.setId(dancers.nextId()));
|
|
importedGroups.forEach((final DanceGroup g) -> {
|
|
g.setId(groups.nextId());
|
|
});
|
|
final Map<DanceGroup, DanceGroup> replacements = new IdentityHashMap<>();
|
|
final List<DanceGroup> rebuiltGroups = rebuildGroups(importedGroups, memberships, replacements);
|
|
final List<Show> rebuiltShows = rebuildShows(importedShows, replacements);
|
|
final DanceSchool imported = DanceSchool.builder()
|
|
.revision(revision)
|
|
.key(staged.getKey())
|
|
.name(staged.getName())
|
|
.dancers(new LinkedHashSet<>(importedDancers))
|
|
.danceGroups(new LinkedHashSet<>(rebuiltGroups))
|
|
.shows(new LinkedHashSet<>(rebuiltShows))
|
|
.coaches(new LinkedHashSet<>(staged.getCoaches()))
|
|
.build();
|
|
if (replaceId != null) {
|
|
deleteSchool(replaceId);
|
|
imported.setId(replaceId);
|
|
}
|
|
importedDancers.forEach(dancers::add);
|
|
rebuiltGroups.forEach(groups::add);
|
|
rebuiltShows.forEach(shows::add);
|
|
return schools.add(imported);
|
|
}
|
|
|
|
private static List<DanceGroup> rebuildGroups(
|
|
final List<DanceGroup> importedGroups,
|
|
final Map<DanceGroup, List<Dancer>> memberships,
|
|
final Map<DanceGroup, DanceGroup> replacements) {
|
|
final List<DanceGroup> rebuiltGroups = new ArrayList<>();
|
|
for (final DanceGroup g : importedGroups) {
|
|
final DanceGroup rebuilt = DanceGroup.builder()
|
|
.category(g.getCategory())
|
|
.type(g.getType())
|
|
.startAge(g.getStartAge())
|
|
.endAge(g.getEndAge())
|
|
.styles(new LinkedHashSet<>(g.getStyles()))
|
|
.coach(g.getCoach())
|
|
.dancers(new LinkedHashSet<>(memberships.get(g)))
|
|
.build();
|
|
rebuilt.setId(g.getId());
|
|
rebuiltGroups.add(rebuilt);
|
|
replacements.put(g, rebuilt);
|
|
}
|
|
return rebuiltGroups;
|
|
}
|
|
|
|
private List<Show> rebuildShows(final List<Show> importedShows, final Map<DanceGroup, DanceGroup> replacements) {
|
|
final List<Show> rebuiltShows = new ArrayList<>();
|
|
for (final Show show : importedShows) {
|
|
final Show rebuilt = Show.builder()
|
|
.name(show.getName())
|
|
.appearances(show.getAppearances())
|
|
.preferredDayParts(show.getPreferredDayParts())
|
|
.dayParts(show.getDayParts().stream()
|
|
.map((final DayPart p) -> DayPart.builder()
|
|
.date(p.getDate())
|
|
.type(p.getType())
|
|
.danceGroups(p.getDanceGroups().stream()
|
|
.map(replacements::get)
|
|
.toList())
|
|
.build())
|
|
.toList())
|
|
.build();
|
|
rebuilt.setId(shows.nextId());
|
|
rebuiltShows.add(rebuilt);
|
|
}
|
|
return rebuiltShows;
|
|
}
|
|
|
|
private static void validateAgeRange(final int startAge, final int endAge) {
|
|
if (startAge > endAge) {
|
|
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "startAge must not exceed endAge");
|
|
}
|
|
}
|
|
|
|
private static <T extends AbstractEntity> T member(final Collection<T> entities, final long id, final String type) {
|
|
return entities.stream()
|
|
.filter((final T entity) -> entity.getId() == id)
|
|
.findFirst()
|
|
.orElseThrow(() -> new ManagementException(
|
|
ManagementExceptionKind.NOT_FOUND, type + " " + id + " not found in this school"));
|
|
}
|
|
|
|
private static <T> T required(final T value, final String type, final long id) {
|
|
if (value == null) {
|
|
throw new ManagementException(ManagementExceptionKind.NOT_FOUND, type + " " + id + " not found");
|
|
}
|
|
return value;
|
|
}
|
|
}
|