diff --git a/java/employee-scheduling/src/main/java/org/acme/employeescheduling/domain/Shift.java b/java/employee-scheduling/src/main/java/org/acme/employeescheduling/domain/Shift.java index 54fcc8aa9e..74cb018844 100644 --- a/java/employee-scheduling/src/main/java/org/acme/employeescheduling/domain/Shift.java +++ b/java/employee-scheduling/src/main/java/org/acme/employeescheduling/domain/Shift.java @@ -1,6 +1,9 @@ package org.acme.employeescheduling.domain; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; import java.util.Objects; import ai.timefold.solver.core.api.domain.entity.PlanningEntity; @@ -89,6 +92,24 @@ public void setEmployee(Employee employee) { this.employee = employee; } + public boolean isOverlappingWithDate(LocalDate date) { + return getStart().toLocalDate().equals(date) || getEnd().toLocalDate().equals(date); + } + + public int getOverlappingDurationInMinutes(LocalDate date) { + LocalDateTime startDateTime = LocalDateTime.of(date, LocalTime.MIN); + LocalDateTime endDateTime = LocalDateTime.of(date, LocalTime.MAX); + return getOverlappingDurationInMinutes(startDateTime, endDateTime, getStart(), getEnd()); + } + + private int getOverlappingDurationInMinutes(LocalDateTime firstStartDateTime, LocalDateTime firstEndDateTime, + LocalDateTime secondStartDateTime, LocalDateTime secondEndDateTime) { + LocalDateTime maxStartTime = firstStartDateTime.isAfter(secondStartDateTime) ? firstStartDateTime : secondStartDateTime; + LocalDateTime minEndTime = firstEndDateTime.isBefore(secondEndDateTime) ? firstEndDateTime : secondEndDateTime; + long minutes = maxStartTime.until(minEndTime, ChronoUnit.MINUTES); + return minutes > 0 ? (int) minutes : 0; + } + @Override public String toString() { return location + " " + start + "-" + end; diff --git a/java/employee-scheduling/src/main/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProvider.java b/java/employee-scheduling/src/main/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProvider.java index 3e5f10f215..9d0c875018 100644 --- a/java/employee-scheduling/src/main/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProvider.java +++ b/java/employee-scheduling/src/main/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProvider.java @@ -1,16 +1,19 @@ package org.acme.employeescheduling.solver; +import static ai.timefold.solver.core.api.score.stream.Joiners.equal; +import static ai.timefold.solver.core.api.score.stream.Joiners.lessThanOrEqual; +import static ai.timefold.solver.core.api.score.stream.Joiners.overlapping; + import java.time.Duration; -import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.Set; +import java.util.function.Function; import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore; 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.Joiners; +import org.acme.employeescheduling.domain.Employee; import org.acme.employeescheduling.domain.Shift; public class EmployeeSchedulingConstraintProvider implements ConstraintProvider { @@ -27,10 +30,6 @@ private static int getMinuteOverlap(Shift shift1, Shift shift2) { (shift1End.isBefore(shift2End)) ? shift1End : shift2End).toMinutes(); } - private static int getShiftDurationInMinutes(Shift shift) { - return (int) Duration.between(shift.getStart(), shift.getEnd()).toMinutes(); - } - @Override public Constraint[] defineConstraints(ConstraintFactory constraintFactory) { return new Constraint[] { @@ -54,18 +53,18 @@ Constraint requiredSkill(ConstraintFactory constraintFactory) { } Constraint noOverlappingShifts(ConstraintFactory constraintFactory) { - return constraintFactory.forEachUniquePair(Shift.class, Joiners.equal(Shift::getEmployee), - Joiners.overlapping(Shift::getStart, Shift::getEnd)) + return constraintFactory.forEachUniquePair(Shift.class, equal(Shift::getEmployee), + overlapping(Shift::getStart, Shift::getEnd)) .penalize(HardSoftScore.ONE_HARD, EmployeeSchedulingConstraintProvider::getMinuteOverlap) .asConstraint("Overlapping shift"); } Constraint atLeast10HoursBetweenTwoShifts(ConstraintFactory constraintFactory) { - return constraintFactory.forEachUniquePair(Shift.class, - Joiners.equal(Shift::getEmployee), - Joiners.lessThanOrEqual(Shift::getEnd, Shift::getStart)) - .filter((firstShift, secondShift) -> Duration.between(firstShift.getEnd(), secondShift.getStart()).toHours() < 10) + return constraintFactory.forEach(Shift.class) + .join(Shift.class, equal(Shift::getEmployee), lessThanOrEqual(Shift::getEnd, Shift::getStart)) + .filter((firstShift, + secondShift) -> Duration.between(firstShift.getEnd(), secondShift.getStart()).toHours() < 10) .penalize(HardSoftScore.ONE_HARD, (firstShift, secondShift) -> { int breakLength = (int) Duration.between(firstShift.getEnd(), secondShift.getStart()).toMinutes(); @@ -75,49 +74,37 @@ Constraint atLeast10HoursBetweenTwoShifts(ConstraintFactory constraintFactory) { } Constraint oneShiftPerDay(ConstraintFactory constraintFactory) { - return constraintFactory.forEachUniquePair(Shift.class, Joiners.equal(Shift::getEmployee), - Joiners.equal(shift -> shift.getStart().toLocalDate())) + return constraintFactory.forEachUniquePair(Shift.class, equal(Shift::getEmployee), + equal(shift -> shift.getStart().toLocalDate())) .penalize(HardSoftScore.ONE_HARD) .asConstraint("Max one shift per day"); } Constraint unavailableEmployee(ConstraintFactory constraintFactory) { return constraintFactory.forEach(Shift.class) - .filter(shift -> { - Set unavailableDates = shift.getEmployee().getUnavailableDates(); - return unavailableDates.contains(shift.getStart().toLocalDate()) - // The contains() check is ignored for a shift ends at midnight (00:00:00). - || (shift.getEnd().isAfter(shift.getStart().toLocalDate().plusDays(1).atStartOfDay()) - && unavailableDates.contains(shift.getEnd().toLocalDate())); - }) - .penalize(HardSoftScore.ONE_HARD, EmployeeSchedulingConstraintProvider::getShiftDurationInMinutes) + .join(Employee.class, equal(Shift::getEmployee, Function.identity())) + .flattenLast(Employee::getUnavailableDates) + .filter(Shift::isOverlappingWithDate) + .penalize(HardSoftScore.ONE_HARD, Shift::getOverlappingDurationInMinutes) .asConstraint("Unavailable employee"); } Constraint undesiredDayForEmployee(ConstraintFactory constraintFactory) { return constraintFactory.forEach(Shift.class) - .filter(shift -> { - Set undesiredDates = shift.getEmployee().getUndesiredDates(); - return undesiredDates.contains(shift.getStart().toLocalDate()) - // The contains() check is ignored for a shift ends at midnight (00:00:00). - || (shift.getEnd().isAfter(shift.getStart().toLocalDate().plusDays(1).atStartOfDay()) - && undesiredDates.contains(shift.getEnd().toLocalDate())); - }) - .penalize(HardSoftScore.ONE_SOFT, EmployeeSchedulingConstraintProvider::getShiftDurationInMinutes) + .join(Employee.class, equal(Shift::getEmployee, Function.identity())) + .flattenLast(Employee::getUndesiredDates) + .filter(Shift::isOverlappingWithDate) + .penalize(HardSoftScore.ONE_SOFT, Shift::getOverlappingDurationInMinutes) .asConstraint("Undesired day for employee"); } Constraint desiredDayForEmployee(ConstraintFactory constraintFactory) { return constraintFactory.forEach(Shift.class) - .filter(shift -> { - Set desiredDates = shift.getEmployee().getDesiredDates(); - return desiredDates.contains(shift.getStart().toLocalDate()) - // The contains() check is ignored for a shift ends at midnight (00:00:00). - || (shift.getEnd().isAfter(shift.getStart().toLocalDate().plusDays(1).atStartOfDay()) - && desiredDates.contains(shift.getEnd().toLocalDate())); - }) - .reward(HardSoftScore.ONE_SOFT, EmployeeSchedulingConstraintProvider::getShiftDurationInMinutes) - .asConstraint("Desired day for employee"); + .join(Employee.class, equal(Shift::getEmployee, Function.identity())) + .flattenLast(Employee::getDesiredDates) + .filter(Shift::isOverlappingWithDate) + .reward(HardSoftScore.ONE_SOFT, Shift::getOverlappingDurationInMinutes) + .asConstraint("Desired day for employee"); } } diff --git a/java/employee-scheduling/src/test/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProviderTest.java b/java/employee-scheduling/src/test/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProviderTest.java index ef3fcf0dbb..627e8679e2 100644 --- a/java/employee-scheduling/src/test/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProviderTest.java +++ b/java/employee-scheduling/src/test/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProviderTest.java @@ -20,6 +20,7 @@ @QuarkusTest class EmployeeSchedulingConstraintProviderTest { private static final LocalDate DAY_1 = LocalDate.of(2021, 2, 1); + private static final LocalDate DAY_3 = LocalDate.of(2021, 2, 3); private static final LocalDateTime DAY_START_TIME = DAY_1.atTime(LocalTime.of(9, 0)); private static final LocalDateTime DAY_END_TIME = DAY_1.atTime(LocalTime.of(17, 0)); @@ -110,6 +111,11 @@ void atLeast10HoursBetweenConsecutiveShifts() { new Shift("1", DAY_START_TIME, DAY_END_TIME, "Location", "Skill", employee1), new Shift("2", DAY_END_TIME, DAY_START_TIME.plusDays(1), "Location 2", "Skill", employee1)) .penalizesBy(600); + constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::atLeast10HoursBetweenTwoShifts) + .given(employee1, employee2, + new Shift("1", DAY_END_TIME, DAY_START_TIME.plusDays(1), "Location", "Skill", employee1), + new Shift("2", DAY_START_TIME, DAY_END_TIME, "Location 2", "Skill", employee1)) + .penalizesBy(600); constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::atLeast10HoursBetweenTwoShifts) .given(employee1, employee2, new Shift("1", DAY_START_TIME, DAY_END_TIME, "Location", "Skill", employee1), @@ -129,7 +135,7 @@ void atLeast10HoursBetweenConsecutiveShifts() { @Test void unavailableEmployee() { - Employee employee1 = new Employee("Amy", null, Set.of(DAY_1), null, null); + Employee employee1 = new Employee("Amy", null, Set.of(DAY_1, DAY_3), null, null); Employee employee2 = new Employee("Beth", null, Set.of(), null, null); constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::unavailableEmployee) .given(employee1, employee2, @@ -138,7 +144,7 @@ void unavailableEmployee() { constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::unavailableEmployee) .given(employee1, employee2, new Shift("1", DAY_START_TIME.minusDays(1), DAY_END_TIME, "Location", "Skill", employee1)) - .penalizesBy((int) Duration.ofHours(32).toMinutes()); + .penalizesBy((int) Duration.ofHours(17).toMinutes()); constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::unavailableEmployee) .given(employee1, employee2, new Shift("1", DAY_START_TIME.plusDays(1), DAY_END_TIME.plusDays(1), "Location", "Skill", employee1)) @@ -151,7 +157,7 @@ void unavailableEmployee() { @Test void undesiredDayForEmployee() { - Employee employee1 = new Employee("Amy", null, null, Set.of(DAY_1), null); + Employee employee1 = new Employee("Amy", null, null, Set.of(DAY_1, DAY_3), null); Employee employee2 = new Employee("Beth", null, null, Set.of(), null); constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::undesiredDayForEmployee) .given(employee1, employee2, @@ -160,7 +166,7 @@ void undesiredDayForEmployee() { constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::undesiredDayForEmployee) .given(employee1, employee2, new Shift("1", DAY_START_TIME.minusDays(1), DAY_END_TIME, "Location", "Skill", employee1)) - .penalizesBy((int) Duration.ofHours(32).toMinutes()); + .penalizesBy((int) Duration.ofHours(17).toMinutes()); constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::undesiredDayForEmployee) .given(employee1, employee2, new Shift("1", DAY_START_TIME.plusDays(1), DAY_END_TIME.plusDays(1), "Location", "Skill", employee1)) @@ -173,7 +179,7 @@ void undesiredDayForEmployee() { @Test void desiredDayForEmployee() { - Employee employee1 = new Employee("Amy", null, null, null, Set.of(DAY_1)); + Employee employee1 = new Employee("Amy", null, null, null, Set.of(DAY_1, DAY_3)); Employee employee2 = new Employee("Beth", null, null, null, Set.of()); constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::desiredDayForEmployee) .given(employee1, employee2, @@ -182,7 +188,7 @@ void desiredDayForEmployee() { constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::desiredDayForEmployee) .given(employee1, employee2, new Shift("1", DAY_START_TIME.minusDays(1), DAY_END_TIME, "Location", "Skill", employee1)) - .rewardsWith((int) Duration.ofHours(32).toMinutes()); + .rewardsWith((int) Duration.ofHours(17).toMinutes()); constraintVerifier.verifyThat(EmployeeSchedulingConstraintProvider::desiredDayForEmployee) .given(employee1, employee2, new Shift("1", DAY_START_TIME.plusDays(1), DAY_END_TIME.plusDays(1), "Location", "Skill", employee1)) diff --git a/python/employee-scheduling/src/employee_scheduling/constraints.py b/python/employee-scheduling/src/employee_scheduling/constraints.py index b922e07d0d..2f6e62a553 100644 --- a/python/employee-scheduling/src/employee_scheduling/constraints.py +++ b/python/employee-scheduling/src/employee_scheduling/constraints.py @@ -1,5 +1,5 @@ from timefold.solver.score import (constraint_provider, ConstraintFactory, Joiners, HardSoftScore) -from datetime import datetime, time, timedelta +from datetime import datetime, time, timedelta, date from .domain import Employee, Shift @@ -8,8 +8,24 @@ def get_minute_overlap(shift1: Shift, shift2: Shift) -> int: return (min(shift1.end, shift2.end) - max(shift1.start, shift2.start)).total_seconds() // 60 -def get_shift_duration_in_minutes(shift: Shift) -> int: - return (shift.end - shift.start).total_seconds() // 60 +def is_overlapping_with_date(shift: Shift, dt: date) -> bool: + return shift.start.date() == dt or shift.end.date() == dt + + +def overlapping_in_minutes(first_start_datetime: datetime, first_end_datetime: datetime, + second_start_datetime: datetime, second_end_datetime: datetime) -> int: + latest_start = max(first_start_datetime, second_start_datetime) + earliest_end = min(first_end_datetime, second_end_datetime) + delta = (earliest_end - latest_start).total_seconds() / 60 + return max(0, delta) + + +def get_shift_overlapping_duration_in_minutes(shift: Shift, dt: date) -> int: + overlap = 0 + start_date_time = datetime.combine(dt, datetime.max.time()) + end_date_time = datetime.combine(dt, datetime.min.time()) + overlap += overlapping_in_minutes(start_date_time, end_date_time, shift.start, shift.end) + return overlap @constraint_provider @@ -73,38 +89,33 @@ def one_shift_per_day(constraint_factory: ConstraintFactory): def unavailable_employee(constraint_factory: ConstraintFactory): return (constraint_factory.for_each(Shift) - .filter(lambda shift: shift.start.date() in shift.employee.unavailable_dates or ( - # The in check is ignored for a shift ends at midnight (00:00:00). - shift.end.time() != datetime.min.time() - and shift.end.date() in shift.employee.unavailable_dates) - ) + .join(Employee, Joiners.equal(lambda shift: shift.employee, lambda employee: employee)) + .flatten_last(lambda employee: employee.unavailable_dates) + .filter(lambda shift, unavailable_date: is_overlapping_with_date(shift, unavailable_date)) .penalize(HardSoftScore.ONE_HARD, - lambda shift: get_shift_duration_in_minutes(shift)) + lambda shift, unavailable_date: get_shift_overlapping_duration_in_minutes(shift, + unavailable_date)) .as_constraint("Unavailable employee") ) def undesired_day_for_employee(constraint_factory: ConstraintFactory): return (constraint_factory.for_each(Shift) - .filter(lambda shift: shift.start.date() in shift.employee.undesired_dates or ( - # The in check is ignored for a shift ends at midnight (00:00:00). - shift.end.time() != datetime.min.time() - and shift.end.date() in shift.employee.undesired_dates) - ) + .join(Employee, Joiners.equal(lambda shift: shift.employee, lambda employee: employee)) + .flatten_last(lambda employee: employee.undesired_dates) + .filter(lambda shift, undesired_date: is_overlapping_with_date(shift, undesired_date)) .penalize(HardSoftScore.ONE_SOFT, - lambda shift: get_shift_duration_in_minutes(shift)) + lambda shift, undesired_date: get_shift_overlapping_duration_in_minutes(shift, undesired_date)) .as_constraint("Undesired day for employee") ) def desired_day_for_employee(constraint_factory: ConstraintFactory): return (constraint_factory.for_each(Shift) - .filter(lambda shift: shift.start.date() in shift.employee.desired_dates or ( - # The in check is ignored for a shift ends at midnight (00:00:00). - shift.end.time() != datetime.min.time() - and shift.end.date() in shift.employee.desired_dates) - ) + .join(Employee, Joiners.equal(lambda shift: shift.employee, lambda employee: employee)) + .flatten_last(lambda employee: employee.desired_dates) + .filter(lambda shift, desired_date: is_overlapping_with_date(shift, desired_date)) .reward(HardSoftScore.ONE_SOFT, - lambda shift: get_shift_duration_in_minutes(shift)) + lambda shift, desired_date: get_shift_overlapping_duration_in_minutes(shift, desired_date)) .as_constraint("Desired day for employee") ) diff --git a/python/employee-scheduling/tests/test_constraints.py b/python/employee-scheduling/tests/test_constraints.py index a8bf22a3a1..acd0f0ed4f 100644 --- a/python/employee-scheduling/tests/test_constraints.py +++ b/python/employee-scheduling/tests/test_constraints.py @@ -1,11 +1,10 @@ from timefold.solver.test import ConstraintVerifier -from datetime import date, datetime, time, timedelta -from employee_scheduling.domain import * from employee_scheduling.constraints import * - +from employee_scheduling.domain import * DAY_1 = date(2021, 2, 1) +DAY_3 = date(2021, 2, 3) DAY_START_TIME = datetime.combine(DAY_1, time(9, 0)) DAY_END_TIME = datetime.combine(DAY_1, time(17, 0)) AFTERNOON_START_TIME = datetime.combine(DAY_1, time(13, 0)) @@ -93,6 +92,12 @@ def test_at_least_10_hours_between_shifts(): Shift(id="1", start=DAY_START_TIME, end=DAY_END_TIME, location="Location", required_skill="Skill", employee=employee1), Shift(id="2", start=DAY_END_TIME, end=DAY_START_TIME + timedelta(days=1), location="Location 2", required_skill="Skill", employee=employee1)) .penalizes_by(600)) + + (constraint_verifier.verify_that(at_least_10_hours_between_two_shifts) + .given(employee1, employee2, + Shift(id="1", start=DAY_END_TIME, end=DAY_START_TIME + timedelta(days=1), location="Location", required_skill="Skill", employee=employee1), + Shift(id="2", start=DAY_START_TIME, end=DAY_END_TIME, location="Location 2", required_skill="Skill", employee=employee1)) + .penalizes_by(600)) (constraint_verifier.verify_that(at_least_10_hours_between_two_shifts) .given(employee1, employee2, @@ -114,7 +119,7 @@ def test_at_least_10_hours_between_shifts(): def test_unavailable_employee(): - employee1 = Employee(name="Amy", unavailable_dates={DAY_1}) + employee1 = Employee(name="Amy", unavailable_dates={DAY_1, DAY_3}) employee2 = Employee(name="Beth") (constraint_verifier.verify_that(unavailable_employee) @@ -125,7 +130,7 @@ def test_unavailable_employee(): (constraint_verifier.verify_that(unavailable_employee) .given(employee1, employee2, Shift(id="1", start=DAY_START_TIME - timedelta(days=1), end=DAY_END_TIME, location="Location", required_skill="Skill", employee=employee1)) - .penalizes_by(timedelta(hours=32) // timedelta(minutes=1))) + .penalizes_by(timedelta(hours=17) // timedelta(minutes=1))) (constraint_verifier.verify_that(unavailable_employee) .given(employee1, employee2, @@ -139,7 +144,7 @@ def test_unavailable_employee(): def test_undesired_day_for_employee(): - employee1 = Employee(name="Amy", undesired_dates={DAY_1}) + employee1 = Employee(name="Amy", undesired_dates={DAY_1, DAY_3}) employee2 = Employee(name="Beth") (constraint_verifier.verify_that(undesired_day_for_employee) @@ -150,7 +155,7 @@ def test_undesired_day_for_employee(): (constraint_verifier.verify_that(undesired_day_for_employee) .given(employee1, employee2, Shift(id="1", start=DAY_START_TIME - timedelta(days=1), end=DAY_END_TIME, location="Location", required_skill="Skill", employee=employee1)) - .penalizes_by(timedelta(hours=32) // timedelta(minutes=1))) + .penalizes_by(timedelta(hours=17) // timedelta(minutes=1))) (constraint_verifier.verify_that(undesired_day_for_employee) .given(employee1, employee2, @@ -164,7 +169,7 @@ def test_undesired_day_for_employee(): def test_desired_day_for_employee(): - employee1 = Employee(name="Amy", desired_dates={DAY_1}) + employee1 = Employee(name="Amy", desired_dates={DAY_1, DAY_3}) employee2 = Employee(name="Beth") (constraint_verifier.verify_that(desired_day_for_employee) @@ -175,7 +180,7 @@ def test_desired_day_for_employee(): (constraint_verifier.verify_that(desired_day_for_employee) .given(employee1, employee2, Shift(id="1", start=DAY_START_TIME - timedelta(days=1), end=DAY_END_TIME, location="Location", required_skill="Skill", employee=employee1)) - .rewards_with(timedelta(hours=32) // timedelta(minutes=1))) + .rewards_with(timedelta(hours=17) // timedelta(minutes=1))) (constraint_verifier.verify_that(desired_day_for_employee) .given(employee1, employee2, @@ -185,4 +190,4 @@ def test_desired_day_for_employee(): (constraint_verifier.verify_that(desired_day_for_employee) .given(employee1, employee2, Shift(id="1", start=DAY_START_TIME, end=DAY_END_TIME, location="Location", required_skill="Skill", employee=employee2)) - .rewards(0)) + .rewards(0)) \ No newline at end of file