avant changement UI

This commit is contained in:
2025-09-21 10:22:59 +02:00
parent 793393b294
commit a9bc1e47db
34 changed files with 859 additions and 156 deletions

View File

@@ -0,0 +1,69 @@
package org.acme.employeescheduling.domain;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class Collecte {
private String id;
private LocalDateTime start;
private LocalDateTime end;
private String location;
private Map<String, Integer> requiredSkills;
private List<Shift> shifts;
public Collecte() {
this.requiredSkills = new HashMap<>();
this.shifts = new ArrayList<>();
}
public Collecte(String id, LocalDateTime start, LocalDateTime end, String location, Map<String, Integer> requiredSkills) {
this.id = id;
this.start = start;
this.end = end;
this.location = location;
this.requiredSkills = requiredSkills != null ? requiredSkills : new HashMap<>();
this.shifts = new ArrayList<>();
}
public void generateShifts() {
if (this.requiredSkills == null) {
this.requiredSkills = new HashMap<>();
}
this.shifts = this.requiredSkills.entrySet().stream()
.flatMap(entry -> {
String skill = entry.getKey();
int quantity = entry.getValue();
return IntStream.range(0, quantity)
.mapToObj(i -> new Shift(
this.id + "_" + skill + "_" + i,
this.start,
this.end,
this.location,
skill,
null, // employee
this // collecte
));
})
.collect(Collectors.toList());
}
// Getters et setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public LocalDateTime getStart() { return start; }
public void setStart(LocalDateTime start) { this.start = start; }
public LocalDateTime getEnd() { return end; }
public void setEnd(LocalDateTime end) { this.end = end; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public Map<String, Integer> getRequiredSkills() { return requiredSkills; }
public void setRequiredSkills(Map<String, Integer> requiredSkills) { this.requiredSkills = requiredSkills; }
public List<Shift> getShifts() { return shifts; }
public void setShifts(List<Shift> shifts) { this.shifts = shifts; }
}

View File

@@ -1,7 +1,8 @@
package org.acme.employeescheduling.domain;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.Collectors;
import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
import ai.timefold.solver.core.api.domain.solution.PlanningScore;
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
@@ -12,61 +13,50 @@ import ai.timefold.solver.core.api.solver.SolverStatus;
@PlanningSolution
public class EmployeeSchedule {
@ProblemFactCollectionProperty
@ValueRangeProvider(id = "employeeRange")
private List<Employee> employees;
@ProblemFactCollectionProperty
@ValueRangeProvider
private List<Employee> employees;
private List<Collecte> collectes;
@PlanningEntityCollectionProperty
private List<Shift> shifts;
@PlanningScore
private HardSoftBigDecimalScore score;
private SolverStatus solverStatus;
// No-arg constructor required for Timefold
public EmployeeSchedule() {}
public EmployeeSchedule(List<Employee> employees, List<Shift> shifts) {
this.employees = employees;
this.shifts = shifts;
// Constructeur vide requis par Timefold
public EmployeeSchedule() {
this.employees = new ArrayList<>();
this.collectes = new ArrayList<>();
this.shifts = new ArrayList<>();
}
public EmployeeSchedule(HardSoftBigDecimalScore score, SolverStatus solverStatus) {
this.score = score;
this.solverStatus = solverStatus;
// Constructeur principal
public EmployeeSchedule(List<Employee> employees, List<Collecte> collectes) {
this.employees = employees != null ? employees : new ArrayList<>();
this.collectes = collectes != null ? collectes : new ArrayList<>();
// Générer les shifts à partir des collectes
this.shifts = collectes != null ?
collectes.stream()
.peek(Collecte::generateShifts)
.flatMap(collecte -> collecte.getShifts().stream())
.collect(Collectors.toList()) :
new ArrayList<>();
}
public List<Employee> getEmployees() {
return employees;
}
public void setEmployees(List<Employee> employees) {
this.employees = employees;
}
public List<Shift> getShifts() {
return shifts;
}
public void setShifts(List<Shift> shifts) {
this.shifts = shifts;
}
public HardSoftBigDecimalScore getScore() {
return score;
}
public void setScore(HardSoftBigDecimalScore score) {
this.score = score;
}
public SolverStatus getSolverStatus() {
return solverStatus;
}
public void setSolverStatus(SolverStatus solverStatus) {
this.solverStatus = solverStatus;
}
// Getters et setters
public List<Employee> getEmployees() { return employees; }
public void setEmployees(List<Employee> employees) { this.employees = employees; }
public List<Collecte> getCollectes() { return collectes; }
public void setCollectes(List<Collecte> collectes) { this.collectes = collectes; }
public List<Shift> getShifts() { return shifts; }
public void setShifts(List<Shift> shifts) { this.shifts = shifts; }
public HardSoftBigDecimalScore getScore() { return score; }
public void setScore(HardSoftBigDecimalScore score) { this.score = score; }
public SolverStatus getSolverStatus() { return solverStatus; }
public void setSolverStatus(SolverStatus solverStatus) { this.solverStatus = solverStatus; }
}

View File

@@ -20,28 +20,30 @@ public class Shift {
private String location;
private String requiredSkill;
private Collecte collecte;
@PlanningVariable
@PlanningVariable(valueRangeProviderRefs = "employeeRange")
private Employee employee;
public Shift() {
}
public Shift(LocalDateTime start, LocalDateTime end, String location, String requiredSkill) {
this(start, end, location, requiredSkill, null);
this(null, start, end, location, requiredSkill, null, null);;
}
public Shift(LocalDateTime start, LocalDateTime end, String location, String requiredSkill, Employee employee) {
this(null, start, end, location, requiredSkill, employee);
this(null, start, end, location, requiredSkill, employee, null);
}
public Shift(String id, LocalDateTime start, LocalDateTime end, String location, String requiredSkill, Employee employee) {
public Shift(String id, LocalDateTime start, LocalDateTime end, String location, String requiredSkill, Employee employee, Collecte collecte) {
this.id = id;
this.start = start;
this.end = end;
this.location = location;
this.requiredSkill = requiredSkill;
this.employee = employee;
this.collecte = collecte;
}
public String getId() {
@@ -88,6 +90,9 @@ public class Shift {
return employee;
}
public Collecte getCollecte() { return collecte; }
public void setCollecte(Collecte collecte) { this.collecte = collecte; }
public void setEmployee(Employee employee) {
this.employee = employee;
}

View File

@@ -4,7 +4,6 @@ import java.util.Collection;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
@@ -17,14 +16,12 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis;
import ai.timefold.solver.core.api.score.buildin.hardsoftbigdecimal.HardSoftBigDecimalScore;
import ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy;
import ai.timefold.solver.core.api.solver.SolutionManager;
import ai.timefold.solver.core.api.solver.SolverManager;
import ai.timefold.solver.core.api.solver.SolverStatus;
import org.acme.employeescheduling.domain.EmployeeSchedule;
import org.acme.employeescheduling.rest.exception.EmployeeScheduleSolverException;
import org.acme.employeescheduling.rest.exception.ErrorInfo;
@@ -42,13 +39,9 @@ import org.slf4j.LoggerFactory;
@Tag(name = "Employee Schedules", description = "Employee Schedules service for assigning employees to shifts.")
@Path("schedules")
public class EmployeeScheduleResource {
private static final Logger LOGGER = LoggerFactory.getLogger(EmployeeScheduleResource.class);
SolverManager<EmployeeSchedule, String> solverManager;
SolutionManager<EmployeeSchedule, HardSoftBigDecimalScore> solutionManager;
// TODO: Without any "time to live", the map may eventually grow out of memory.
private final ConcurrentMap<String, Job> jobIdToJob = new ConcurrentHashMap<>();
@Inject
@@ -77,20 +70,36 @@ public class EmployeeScheduleResource {
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces(MediaType.TEXT_PLAIN)
public String solve(EmployeeSchedule problem) {
String jobId = UUID.randomUUID().toString();
jobIdToJob.put(jobId, Job.ofSchedule(problem));
solverManager.solveBuilder()
.withProblemId(jobId)
.withProblemFinder(jobId_ -> jobIdToJob.get(jobId).schedule)
.withBestSolutionConsumer(solution -> jobIdToJob.put(jobId, Job.ofSchedule(solution)))
.withExceptionHandler((jobId_, exception) -> {
jobIdToJob.put(jobId, Job.ofException(exception));
LOGGER.error("Failed solving jobId ({}).", jobId, exception);
})
.run();
return jobId;
}
public String solve(EmployeeSchedule problem) {
String jobId = UUID.randomUUID().toString();
LOGGER.info("Submitting problem with jobId: {}", jobId);
LOGGER.info("Problem details - Employees: {}, Collectes: {}",
problem.getEmployees().size(),
problem.getCollectes().size());
jobIdToJob.put(jobId, Job.ofSchedule(problem));
LOGGER.info("Starting solver for jobId: {}", jobId);
solverManager.solveBuilder()
.withProblemId(jobId)
.withProblemFinder(jobId_ -> {
Job job = jobIdToJob.get(jobId);
LOGGER.debug("Problem finder called for jobId: {}, problem: {}", jobId, job != null ? job.schedule : null);
return job != null ? job.schedule : null;
})
.withBestSolutionConsumer(solution -> {
LOGGER.info("New best solution for jobId: {}", jobId);
jobIdToJob.put(jobId, Job.ofSchedule(solution));
})
.withExceptionHandler((jobId_, exception) -> {
LOGGER.error("Exception during solving jobId {}: {}", jobId, exception.getMessage(), exception);
jobIdToJob.put(jobId, Job.ofException(exception));
})
.run();
LOGGER.info("Solver started for jobId: {}", jobId);
return jobId;
}
@Operation(summary = "Submit a schedule to analyze its score.")
@APIResponses(value = {
@@ -131,17 +140,6 @@ public class EmployeeScheduleResource {
return schedule;
}
private EmployeeSchedule getEmployeeScheduleAndCheckForExceptions(String jobId) {
Job job = jobIdToJob.get(jobId);
if (job == null) {
throw new EmployeeScheduleSolverException(jobId, Response.Status.NOT_FOUND, "No schedule found.");
}
if (job.exception != null) {
throw new EmployeeScheduleSolverException(jobId, job.exception);
}
return job.schedule;
}
@Operation(
summary = "Terminate solving for a given job ID. Returns the best solution of the schedule so far, as it might still be running or not even started.")
@APIResponses(value = {
@@ -160,7 +158,6 @@ public class EmployeeScheduleResource {
@Path("{jobId}")
public EmployeeSchedule terminateSolving(
@Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) {
// TODO: Replace with .terminateEarlyAndWait(... [, timeout]); see https://github.com/TimefoldAI/timefold-solver/issues/77
solverManager.terminateEarly(jobId);
return getEmployeeSchedule(jobId);
}
@@ -170,7 +167,7 @@ public class EmployeeScheduleResource {
@APIResponses(value = {
@APIResponse(responseCode = "200", description = "The schedule status and the best score so far.",
content = @Content(mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = EmployeeSchedule.class))),
schema = @Schema(implementation = ScheduleStatus.class))),
@APIResponse(responseCode = "404", description = "No schedule found.",
content = @Content(mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = ErrorInfo.class))),
@@ -181,21 +178,50 @@ public class EmployeeScheduleResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("{jobId}/status")
public EmployeeSchedule getStatus(
public Response getStatus(
@Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) {
EmployeeSchedule schedule = getEmployeeScheduleAndCheckForExceptions(jobId);
SolverStatus solverStatus = solverManager.getSolverStatus(jobId);
return new EmployeeSchedule(schedule.getScore(), solverStatus);
return Response.ok(new ScheduleStatus(schedule.getScore(), solverStatus)).build();
}
private EmployeeSchedule getEmployeeScheduleAndCheckForExceptions(String jobId) {
Job job = jobIdToJob.get(jobId);
if (job == null) {
throw new EmployeeScheduleSolverException(jobId, Response.Status.NOT_FOUND, "No schedule found.");
}
if (job.exception != null) {
throw new EmployeeScheduleSolverException(jobId, job.exception);
}
return job.schedule;
}
private record Job(EmployeeSchedule schedule, Throwable exception) {
static Job ofSchedule(EmployeeSchedule schedule) {
return new Job(schedule, null);
}
static Job ofException(Throwable error) {
return new Job(null, error);
}
}
public static class ScheduleStatus {
private HardSoftBigDecimalScore score;
private SolverStatus solverStatus;
public ScheduleStatus(HardSoftBigDecimalScore score, SolverStatus solverStatus) {
this.score = score;
this.solverStatus = solverStatus;
}
// Getters
public HardSoftBigDecimalScore getScore() {
return score;
}
public SolverStatus getSolverStatus() {
return solverStatus;
}
}
}

View File

@@ -0,0 +1,30 @@
package org.acme.employeescheduling.solver;
import ai.timefold.solver.core.api.score.buildin.hardsoftbigdecimal.HardSoftBigDecimalScore;
import ai.timefold.solver.core.api.score.stream.Constraint;
import ai.timefold.solver.core.api.score.stream.ConstraintFactory;
import ai.timefold.solver.core.api.score.stream.ConstraintProvider;
import ai.timefold.solver.core.api.score.stream.ConstraintCollectors;
// import org.acme.employeescheduling.domain.Collecte;
import org.acme.employeescheduling.domain.Shift;
public class CollecteConstraintProvider implements ConstraintProvider {
@Override
public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
return new Constraint[] {
requiredSkills(constraintFactory)
};
}
private Constraint requiredSkills(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.groupBy(Shift::getCollecte, Shift::getRequiredSkill, ConstraintCollectors.count())
.penalize(HardSoftBigDecimalScore.ONE_HARD,
(collecte, skill, count) -> {
int requiredQuantity = collecte.getRequiredSkills().getOrDefault(skill, 0);
return Math.max(0, requiredQuantity - count);
})
.asConstraint("Insufficient employees with required skill");
}
}

View File

@@ -48,19 +48,20 @@ public class EmployeeSchedulingConstraintProvider implements ConstraintProvider
};
}
Constraint requiredSkill(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.filter(shift -> !shift.getEmployee().getSkills().contains(shift.getRequiredSkill()))
.penalize(HardSoftBigDecimalScore.ONE_HARD)
.asConstraint("Missing required skill");
private Constraint requiredSkill(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.filter(shift -> shift.getEmployee() != null &&
!shift.getEmployee().getSkills().contains(shift.getRequiredSkill()))
.penalize(HardSoftBigDecimalScore.ONE_HARD)
.asConstraint("Missing required skill");
}
Constraint noOverlappingShifts(ConstraintFactory constraintFactory) {
return constraintFactory.forEachUniquePair(Shift.class, equal(Shift::getEmployee),
overlapping(Shift::getStart, Shift::getEnd))
.penalize(HardSoftBigDecimalScore.ONE_HARD,
EmployeeSchedulingConstraintProvider::getMinuteOverlap)
.asConstraint("Overlapping shift");
private Constraint noOverlappingShifts(ConstraintFactory constraintFactory) {
return constraintFactory.forEachUniquePair(Shift.class,
equal(Shift::getEmployee),
overlapping(shift -> shift.getStart(), shift -> shift.getEnd()))
.penalize(HardSoftBigDecimalScore.ONE_HARD)
.asConstraint("Overlapping shift");
}
Constraint atLeast10HoursBetweenTwoShifts(ConstraintFactory constraintFactory) {

View File

@@ -41,3 +41,6 @@ quarkus.swagger-ui.always-include=true
########################
%test.quarkus.timefold.solver.termination.spent-limit=10s
quarkus.log.category."ai.timefold.solver".level=DEBUG
quarkus.log.category."org.acme.employeescheduling".level=DEBUG