init
This commit is contained in:
commit
3da9b43ff6
154
README.MD
Normal file
154
README.MD
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# Employee Scheduling (Java, Quarkus, Maven)
|
||||||
|
|
||||||
|
Schedule shifts to employees, accounting for employee availability and shift skill requirements.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- [Run the application](#run-the-application)
|
||||||
|
- [Run the application with Timefold Solver Enterprise Edition](#run-the-application-with-timefold-solver-enterprise-edition)
|
||||||
|
- [Run the packaged application](#run-the-packaged-application)
|
||||||
|
- [Run the application in a container](#run-the-application-in-a-container)
|
||||||
|
- [Run it native](#run-it-native)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> <img src="https://docs.timefold.ai/_/img/models/employee-shift-scheduling.svg" align="right" width="50px" /> [Check out our off-the-shelf model for Employee Shift Scheduling](https://app.timefold.ai/models/employee-scheduling/v1). This model supports many additional constraints such as skills, pairing employees, fairness and more.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Install Java and Maven, for example with [Sdkman](https://sdkman.io):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ sdk install java
|
||||||
|
$ sdk install maven
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run the application
|
||||||
|
|
||||||
|
1. Git clone the timefold-quickstarts repo and navigate to this directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ git clone https://github.com/TimefoldAI/timefold-quickstarts.git
|
||||||
|
...
|
||||||
|
$ cd timefold-quickstarts/java/employee-scheduling
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the application with Maven:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ mvn quarkus:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Visit [http://localhost:8080](http://localhost:8080) in your browser.
|
||||||
|
|
||||||
|
4. Click on the **Solve** button.
|
||||||
|
|
||||||
|
Then try _live coding_:
|
||||||
|
|
||||||
|
- Make some changes in the source code.
|
||||||
|
- Refresh your browser (F5).
|
||||||
|
|
||||||
|
Notice that those changes are immediately in effect.
|
||||||
|
|
||||||
|
## Run the application with Timefold Solver Enterprise Edition
|
||||||
|
|
||||||
|
For high-scalability use cases, switch to [Timefold Solver Enterprise Edition](https://docs.timefold.ai/timefold-solver/latest/enterprise-edition/enterprise-edition), our commercial offering.
|
||||||
|
[Contact Timefold](https://timefold.ai/contact) to obtain the credentials required to access our private Enterprise Maven repository.
|
||||||
|
|
||||||
|
1. Create `.m2/settings.xml` in your home directory with the following content:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<settings>
|
||||||
|
...
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<!-- Replace "my_username" and "my_password" with credentials obtained from a Timefold representative. -->
|
||||||
|
<id>timefold-solver-enterprise</id>
|
||||||
|
<username>my_username</username>
|
||||||
|
<password>my_password</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
...
|
||||||
|
</settings>
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Settings Reference](https://maven.apache.org/settings.html) for more information on Maven settings.
|
||||||
|
|
||||||
|
2. Start the application with Maven:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ mvn clean quarkus:dev -Denterprise
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Visit [http://localhost:8080](http://localhost:8080) in your browser.
|
||||||
|
|
||||||
|
4. Click on the **Solve** button.
|
||||||
|
|
||||||
|
Then try _live coding_:
|
||||||
|
|
||||||
|
- Make some changes in the source code.
|
||||||
|
- Refresh your browser (F5).
|
||||||
|
|
||||||
|
Notice that those changes are immediately in effect.
|
||||||
|
|
||||||
|
## Run the packaged application
|
||||||
|
|
||||||
|
When you're done iterating in `quarkus:dev` mode, package the application to run as a conventional jar file.
|
||||||
|
|
||||||
|
1. Compile it with Maven:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ mvn package
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ java -jar ./target/quarkus-app/quarkus-run.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**
|
||||||
|
> To run it on port 8081 instead, add `-Dquarkus.http.port=8081`.
|
||||||
|
|
||||||
|
3. Visit [http://localhost:8080](http://localhost:8080) in your browser.
|
||||||
|
|
||||||
|
4. Click on the **Solve** button.
|
||||||
|
|
||||||
|
## Run the application in a container
|
||||||
|
|
||||||
|
1. Build a container image:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ mvn package -Dcontainer
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run a container:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ docker run -p 8080:8080 $USER/employee-scheduling:1.0-SNAPSHOT
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run it native
|
||||||
|
|
||||||
|
To increase startup performance for serverless deployments, build the application as a native executable:
|
||||||
|
|
||||||
|
1. [Install GraalVM and `gu` install the native-image tool](https://quarkus.io/guides/building-native-image#configuring-graalvm).
|
||||||
|
|
||||||
|
2. Compile it natively. This takes a few minutes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ mvn package -Dnative -DskipTests
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the native executable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ ./target/*-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Visit [http://localhost:8080](http://localhost:8080) in your browser.
|
||||||
|
|
||||||
|
5. Click on the **Solve** button.
|
||||||
|
|
||||||
|
## More information
|
||||||
|
|
||||||
|
Visit [timefold.ai](https://timefold.ai).
|
||||||
41
chat.json
Normal file
41
chat.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"employees": [
|
||||||
|
{
|
||||||
|
"name": "Employee1",
|
||||||
|
"skills": ["Skill1", "Skill2"],
|
||||||
|
"unavailableDates": ["2025-01-01", "2025-01-02"],
|
||||||
|
"undesiredDates": ["2025-01-03"],
|
||||||
|
"desiredDates": ["2025-01-04"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Employee2",
|
||||||
|
"skills": ["Skill2", "Skill3"],
|
||||||
|
"unavailableDates": ["2025-01-02"],
|
||||||
|
"undesiredDates": ["2025-01-05"],
|
||||||
|
"desiredDates": ["2025-01-06"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shifts": [
|
||||||
|
{
|
||||||
|
"id": "Shift1",
|
||||||
|
"start": "2025-01-01T08:00:00",
|
||||||
|
"end": "2025-01-01T16:00:00",
|
||||||
|
"location": "Location1",
|
||||||
|
"requiredSkill": "Skill1",
|
||||||
|
"employee": {
|
||||||
|
"name": "Employee1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Shift2",
|
||||||
|
"start": "2025-01-02T08:00:00",
|
||||||
|
"end": "2025-01-02T16:00:00",
|
||||||
|
"location": "Location2",
|
||||||
|
"requiredSkill": "Skill2",
|
||||||
|
"employee": {
|
||||||
|
"name": "Employee2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
162
claude.json
Normal file
162
claude.json
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
{
|
||||||
|
"employees": [
|
||||||
|
{
|
||||||
|
"name": "Marie Dupont",
|
||||||
|
"skills": ["INFIRMIER", "PRELEVEMENT"],
|
||||||
|
"unavailableDates": ["2024-12-25", "2024-12-26"],
|
||||||
|
"undesiredDates": ["2024-12-24", "2024-12-31"],
|
||||||
|
"desiredDates": ["2024-12-20", "2024-12-21"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pierre Martin",
|
||||||
|
"skills": ["MEDECIN", "SUPERVISION"],
|
||||||
|
"unavailableDates": ["2024-12-30"],
|
||||||
|
"undesiredDates": ["2024-12-29"],
|
||||||
|
"desiredDates": ["2024-12-22", "2024-12-23"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sophie Bernard",
|
||||||
|
"skills": ["INFIRMIER", "ACCUEIL"],
|
||||||
|
"unavailableDates": [],
|
||||||
|
"undesiredDates": ["2024-12-25"],
|
||||||
|
"desiredDates": ["2024-12-24", "2024-12-28"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jean Leroy",
|
||||||
|
"skills": ["PRELEVEMENT", "TRANSPORT"],
|
||||||
|
"unavailableDates": ["2024-12-24", "2024-12-25"],
|
||||||
|
"undesiredDates": [],
|
||||||
|
"desiredDates": ["2024-12-27", "2024-12-30"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Anne Moreau",
|
||||||
|
"skills": ["MEDECIN", "INFIRMIER"],
|
||||||
|
"unavailableDates": ["2024-12-26"],
|
||||||
|
"undesiredDates": ["2024-12-31"],
|
||||||
|
"desiredDates": ["2024-12-21", "2024-12-29"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Luc Petit",
|
||||||
|
"skills": ["ACCUEIL", "TRANSPORT"],
|
||||||
|
"unavailableDates": [],
|
||||||
|
"undesiredDates": ["2024-12-20"],
|
||||||
|
"desiredDates": ["2024-12-25", "2024-12-26"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shifts": [
|
||||||
|
{
|
||||||
|
"id": "shift_001",
|
||||||
|
"start": "2024-12-20T08:00:00",
|
||||||
|
"end": "2024-12-20T16:00:00",
|
||||||
|
"location": "Centre de collecte - Toulouse",
|
||||||
|
"requiredSkill": "INFIRMIER",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_002",
|
||||||
|
"start": "2024-12-20T14:00:00",
|
||||||
|
"end": "2024-12-20T22:00:00",
|
||||||
|
"location": "Centre de collecte - Toulouse",
|
||||||
|
"requiredSkill": "PRELEVEMENT",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_003",
|
||||||
|
"start": "2024-12-21T06:00:00",
|
||||||
|
"end": "2024-12-21T14:00:00",
|
||||||
|
"location": "Hôpital Purpan",
|
||||||
|
"requiredSkill": "MEDECIN",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_004",
|
||||||
|
"start": "2024-12-21T08:00:00",
|
||||||
|
"end": "2024-12-21T12:00:00",
|
||||||
|
"location": "Centre de collecte - Colomiers",
|
||||||
|
"requiredSkill": "ACCUEIL",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_005",
|
||||||
|
"start": "2024-12-22T09:00:00",
|
||||||
|
"end": "2024-12-22T17:00:00",
|
||||||
|
"location": "Centre de collecte - Blagnac",
|
||||||
|
"requiredSkill": "INFIRMIER",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_006",
|
||||||
|
"start": "2024-12-22T13:00:00",
|
||||||
|
"end": "2024-12-22T18:00:00",
|
||||||
|
"location": "Transport mobile",
|
||||||
|
"requiredSkill": "TRANSPORT",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_007",
|
||||||
|
"start": "2024-12-23T07:00:00",
|
||||||
|
"end": "2024-12-23T15:00:00",
|
||||||
|
"location": "Hôpital Rangueil",
|
||||||
|
"requiredSkill": "MEDECIN",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_008",
|
||||||
|
"start": "2024-12-23T10:00:00",
|
||||||
|
"end": "2024-12-23T16:00:00",
|
||||||
|
"location": "Centre de collecte - Toulouse",
|
||||||
|
"requiredSkill": "PRELEVEMENT",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_009",
|
||||||
|
"start": "2024-12-24T08:00:00",
|
||||||
|
"end": "2024-12-24T14:00:00",
|
||||||
|
"location": "Centre de collecte - Colomiers",
|
||||||
|
"requiredSkill": "INFIRMIER",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_010",
|
||||||
|
"start": "2024-12-27T09:00:00",
|
||||||
|
"end": "2024-12-27T17:00:00",
|
||||||
|
"location": "Centre de collecte - Toulouse",
|
||||||
|
"requiredSkill": "SUPERVISION",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_011",
|
||||||
|
"start": "2024-12-28T08:00:00",
|
||||||
|
"end": "2024-12-28T16:00:00",
|
||||||
|
"location": "Centre de collecte - Blagnac",
|
||||||
|
"requiredSkill": "ACCUEIL",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_012",
|
||||||
|
"start": "2024-12-29T06:00:00",
|
||||||
|
"end": "2024-12-29T14:00:00",
|
||||||
|
"location": "Hôpital Purpan",
|
||||||
|
"requiredSkill": "MEDECIN",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_013",
|
||||||
|
"start": "2024-12-30T10:00:00",
|
||||||
|
"end": "2024-12-30T18:00:00",
|
||||||
|
"location": "Transport mobile",
|
||||||
|
"requiredSkill": "TRANSPORT",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_014",
|
||||||
|
"start": "2024-12-31T08:00:00",
|
||||||
|
"end": "2024-12-31T16:00:00",
|
||||||
|
"location": "Centre de collecte - Toulouse",
|
||||||
|
"requiredSkill": "INFIRMIER",
|
||||||
|
"employee": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": null,
|
||||||
|
"solverStatus": null
|
||||||
|
}
|
||||||
BIN
employee-scheduling-screenshot.png
Normal file
BIN
employee-scheduling-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
60
gemini.json
Normal file
60
gemini.json
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"employees": [
|
||||||
|
{
|
||||||
|
"name": "Alice",
|
||||||
|
"skills": ["Analyse", "Gestion de projet"],
|
||||||
|
"unavailableDates": ["2025-09-22"],
|
||||||
|
"undesiredDates": [],
|
||||||
|
"desiredDates": ["2025-09-24"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bob",
|
||||||
|
"skills": ["Développement", "Analyse"],
|
||||||
|
"unavailableDates": [],
|
||||||
|
"undesiredDates": ["2025-09-23"],
|
||||||
|
"desiredDates": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Charlie",
|
||||||
|
"skills": ["Développement"],
|
||||||
|
"unavailableDates": [],
|
||||||
|
"undesiredDates": [],
|
||||||
|
"desiredDates": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shifts": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"start": "2025-09-21T09:00:00",
|
||||||
|
"end": "2025-09-21T17:00:00",
|
||||||
|
"location": "Bureau de Paris",
|
||||||
|
"requiredSkill": "Analyse",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"start": "2025-09-22T10:00:00",
|
||||||
|
"end": "2025-09-22T18:00:00",
|
||||||
|
"location": "Bureau de Lyon",
|
||||||
|
"requiredSkill": "Développement",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"start": "2025-09-24T14:00:00",
|
||||||
|
"end": "2025-09-24T20:00:00",
|
||||||
|
"location": "Bureau de Paris",
|
||||||
|
"requiredSkill": "Analyse",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4",
|
||||||
|
"start": "2025-09-25T08:00:00",
|
||||||
|
"end": "2025-09-25T16:00:00",
|
||||||
|
"location": "Bureau de Lyon",
|
||||||
|
"requiredSkill": "Développement",
|
||||||
|
"employee": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
54
gpt.json
Normal file
54
gpt.json
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"employees": [
|
||||||
|
{
|
||||||
|
"name": "Alice",
|
||||||
|
"skills": ["Nurse", "FirstAid"],
|
||||||
|
"unavailableDates": ["2025-09-25"],
|
||||||
|
"undesiredDates": ["2025-09-27"],
|
||||||
|
"desiredDates": ["2025-09-28"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bob",
|
||||||
|
"skills": ["Doctor"],
|
||||||
|
"unavailableDates": [],
|
||||||
|
"undesiredDates": [],
|
||||||
|
"desiredDates": ["2025-09-25", "2025-09-26"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Charlie",
|
||||||
|
"skills": ["Nurse"],
|
||||||
|
"unavailableDates": ["2025-09-26"],
|
||||||
|
"undesiredDates": [],
|
||||||
|
"desiredDates": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shifts": [
|
||||||
|
{
|
||||||
|
"id": "S1",
|
||||||
|
"start": "2025-09-25T08:00:00",
|
||||||
|
"end": "2025-09-25T16:00:00",
|
||||||
|
"location": "Hospital A",
|
||||||
|
"requiredSkill": "Nurse",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S2",
|
||||||
|
"start": "2025-09-25T16:00:00",
|
||||||
|
"end": "2025-09-25T23:59:59",
|
||||||
|
"location": "Hospital A",
|
||||||
|
"requiredSkill": "Doctor",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S3",
|
||||||
|
"start": "2025-09-26T08:00:00",
|
||||||
|
"end": "2025-09-26T16:00:00",
|
||||||
|
"location": "Hospital B",
|
||||||
|
"requiredSkill": "Nurse",
|
||||||
|
"employee": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": null,
|
||||||
|
"solverStatus": null
|
||||||
|
}
|
||||||
|
|
||||||
245
pom.xml
Normal file
245
pom.xml
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>org.acme</groupId>
|
||||||
|
<artifactId>employee-scheduling</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>17</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
|
||||||
|
<version.io.quarkus>3.26.2</version.io.quarkus>
|
||||||
|
<version.ai.timefold.solver>1.26.0</version.ai.timefold.solver>
|
||||||
|
|
||||||
|
<version.compiler.plugin>3.14.0</version.compiler.plugin>
|
||||||
|
<version.resources.plugin>3.3.1</version.resources.plugin>
|
||||||
|
<version.surefire.plugin>3.5.3</version.surefire.plugin>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-bom</artifactId>
|
||||||
|
<version>${version.io.quarkus}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>ai.timefold.solver</groupId>
|
||||||
|
<artifactId>timefold-solver-bom</artifactId>
|
||||||
|
<version>${version.ai.timefold.solver}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-smallrye-openapi</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>ai.timefold.solver</groupId>
|
||||||
|
<artifactId>timefold-solver-quarkus</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>ai.timefold.solver</groupId>
|
||||||
|
<artifactId>timefold-solver-quarkus-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- UI -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-web-dependency-locator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>ai.timefold.solver</groupId>
|
||||||
|
<artifactId>timefold-solver-webui</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.webjars</groupId>
|
||||||
|
<artifactId>bootstrap</artifactId>
|
||||||
|
<version>5.2.3</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.webjars</groupId>
|
||||||
|
<artifactId>jquery</artifactId>
|
||||||
|
<version>3.6.4</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.webjars</groupId>
|
||||||
|
<artifactId>font-awesome</artifactId>
|
||||||
|
<version>5.15.1</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.webjars.npm</groupId>
|
||||||
|
<artifactId>js-joda</artifactId>
|
||||||
|
<version>1.11.0</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Testing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>ai.timefold.solver</groupId>
|
||||||
|
<artifactId>timefold-solver-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit5</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.rest-assured</groupId>
|
||||||
|
<artifactId>rest-assured</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.awaitility</groupId>
|
||||||
|
<artifactId>awaitility</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.assertj</groupId>
|
||||||
|
<artifactId>assertj-core</artifactId>
|
||||||
|
<version>3.27.4</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
|
<version>${version.resources.plugin}</version>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>${version.compiler.plugin}</version>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-maven-plugin</artifactId>
|
||||||
|
<version>${version.io.quarkus}</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>${version.surefire.plugin}</version>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>native</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>native</name>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-failsafe-plugin</artifactId>
|
||||||
|
<version>${version.surefire.plugin}</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>integration-test</goal>
|
||||||
|
<goal>verify</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<native.image.path>${project.build.directory}/${project.build.finalName}-runner</native.image.path>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
<properties>
|
||||||
|
<quarkus.native.enabled>true</quarkus.native.enabled>
|
||||||
|
<!-- To allow application.properties to avoid using the in-memory database for native builds. -->
|
||||||
|
<quarkus.profile>native</quarkus.profile>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
<profile>
|
||||||
|
<id>container</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>container</name>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-container-image-jib</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
<properties>
|
||||||
|
<quarkus.container-image.build>true</quarkus.container-image.build>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
<profile>
|
||||||
|
<id>enterprise</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>enterprise</name>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>timefold-solver-enterprise</id>
|
||||||
|
<name>Timefold Solver Enterprise Edition</name>
|
||||||
|
<url>https://timefold.jfrog.io/artifactory/releases/</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>ai.timefold.solver.enterprise</groupId>
|
||||||
|
<artifactId>timefold-solver-enterprise-bom</artifactId>
|
||||||
|
<version>${version.ai.timefold.solver}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>ai.timefold.solver.enterprise</groupId>
|
||||||
|
<artifactId>timefold-solver-enterprise-quarkus</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
<properties>
|
||||||
|
<quarkus.profile>enterprise</quarkus.profile>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
|
||||||
|
</project>
|
||||||
1
sample.json
Normal file
1
sample.json
Normal file
File diff suppressed because one or more lines are too long
1
solution.json
Normal file
1
solution.json
Normal file
File diff suppressed because one or more lines are too long
@ -0,0 +1,91 @@
|
|||||||
|
package org.acme.employeescheduling.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import ai.timefold.solver.core.api.domain.lookup.PlanningId;
|
||||||
|
|
||||||
|
public class Employee {
|
||||||
|
@PlanningId
|
||||||
|
private String name;
|
||||||
|
private Set<String> skills;
|
||||||
|
|
||||||
|
private Set<LocalDate> unavailableDates;
|
||||||
|
private Set<LocalDate> undesiredDates;
|
||||||
|
private Set<LocalDate> desiredDates;
|
||||||
|
|
||||||
|
public Employee() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public Employee(String name, Set<String> skills,
|
||||||
|
Set<LocalDate> unavailableDates, Set<LocalDate> undesiredDates, Set<LocalDate> desiredDates) {
|
||||||
|
this.name = name;
|
||||||
|
this.skills = skills;
|
||||||
|
this.unavailableDates = unavailableDates;
|
||||||
|
this.undesiredDates = undesiredDates;
|
||||||
|
this.desiredDates = desiredDates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> getSkills() {
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSkills(Set<String> skills) {
|
||||||
|
this.skills = skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<LocalDate> getUnavailableDates() {
|
||||||
|
return unavailableDates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUnavailableDates(Set<LocalDate> unavailableDates) {
|
||||||
|
this.unavailableDates = unavailableDates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<LocalDate> getUndesiredDates() {
|
||||||
|
return undesiredDates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUndesiredDates(Set<LocalDate> undesiredDates) {
|
||||||
|
this.undesiredDates = undesiredDates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<LocalDate> getDesiredDates() {
|
||||||
|
return desiredDates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDesiredDates(Set<LocalDate> desiredDates) {
|
||||||
|
this.desiredDates = desiredDates;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(o instanceof Employee employee)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Objects.equals(getName(), employee.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return getName().hashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
package org.acme.employeescheduling.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
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;
|
||||||
|
import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty;
|
||||||
|
import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
|
||||||
|
import ai.timefold.solver.core.api.score.buildin.hardsoftbigdecimal.HardSoftBigDecimalScore;
|
||||||
|
import ai.timefold.solver.core.api.solver.SolverStatus;
|
||||||
|
|
||||||
|
@PlanningSolution
|
||||||
|
public class EmployeeSchedule {
|
||||||
|
|
||||||
|
@ProblemFactCollectionProperty
|
||||||
|
@ValueRangeProvider
|
||||||
|
private List<Employee> employees;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmployeeSchedule(HardSoftBigDecimalScore score, SolverStatus solverStatus) {
|
||||||
|
this.score = score;
|
||||||
|
this.solverStatus = solverStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/main/java/org/acme/employeescheduling/domain/Shift.java
Normal file
133
src/main/java/org/acme/employeescheduling/domain/Shift.java
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
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;
|
||||||
|
import ai.timefold.solver.core.api.domain.lookup.PlanningId;
|
||||||
|
import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
|
||||||
|
|
||||||
|
@PlanningEntity
|
||||||
|
public class Shift {
|
||||||
|
@PlanningId
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
private LocalDateTime start;
|
||||||
|
private LocalDateTime end;
|
||||||
|
|
||||||
|
private String location;
|
||||||
|
private String requiredSkill;
|
||||||
|
|
||||||
|
@PlanningVariable
|
||||||
|
private Employee employee;
|
||||||
|
|
||||||
|
public Shift() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Shift(LocalDateTime start, LocalDateTime end, String location, String requiredSkill) {
|
||||||
|
this(start, end, location, requiredSkill, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Shift(LocalDateTime start, LocalDateTime end, String location, String requiredSkill, Employee employee) {
|
||||||
|
this(null, start, end, location, requiredSkill, employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Shift(String id, LocalDateTime start, LocalDateTime end, String location, String requiredSkill, Employee employee) {
|
||||||
|
this.id = id;
|
||||||
|
this.start = start;
|
||||||
|
this.end = end;
|
||||||
|
this.location = location;
|
||||||
|
this.requiredSkill = requiredSkill;
|
||||||
|
this.employee = employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 String getRequiredSkill() {
|
||||||
|
return requiredSkill;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequiredSkill(String requiredSkill) {
|
||||||
|
this.requiredSkill = requiredSkill;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Employee getEmployee() {
|
||||||
|
return employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(o instanceof Shift shift)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Objects.equals(getId(), shift.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return getId().hashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,240 @@
|
|||||||
|
package org.acme.employeescheduling.rest;
|
||||||
|
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.temporal.TemporalAdjusters;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Random;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
|
import org.acme.employeescheduling.domain.Employee;
|
||||||
|
import org.acme.employeescheduling.domain.EmployeeSchedule;
|
||||||
|
import org.acme.employeescheduling.domain.Shift;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class DemoDataGenerator {
|
||||||
|
public enum DemoData {
|
||||||
|
SMALL(new DemoDataParameters(
|
||||||
|
List.of("Ambulatory care", "Critical care", "Pediatric care"),
|
||||||
|
List.of("Doctor", "Nurse"),
|
||||||
|
List.of("Anaesthetics", "Cardiology"),
|
||||||
|
14,
|
||||||
|
15,
|
||||||
|
List.of(new CountDistribution(1, 3),
|
||||||
|
new CountDistribution(2, 1)
|
||||||
|
),
|
||||||
|
List.of(new CountDistribution(1, 0.9),
|
||||||
|
new CountDistribution(2, 0.1)
|
||||||
|
),
|
||||||
|
List.of(new CountDistribution(1, 4),
|
||||||
|
new CountDistribution(2, 3),
|
||||||
|
new CountDistribution(3, 2),
|
||||||
|
new CountDistribution(4, 1)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
)),
|
||||||
|
LARGE(new DemoDataParameters(
|
||||||
|
List.of("Ambulatory care",
|
||||||
|
"Neurology",
|
||||||
|
"Critical care",
|
||||||
|
"Pediatric care",
|
||||||
|
"Surgery",
|
||||||
|
"Radiology",
|
||||||
|
"Outpatient"),
|
||||||
|
List.of("Doctor", "Nurse"),
|
||||||
|
List.of("Anaesthetics", "Cardiology", "Radiology"),
|
||||||
|
28,
|
||||||
|
50,
|
||||||
|
List.of(new CountDistribution(1, 3),
|
||||||
|
new CountDistribution(2, 1)
|
||||||
|
),
|
||||||
|
List.of(new CountDistribution(1, 0.5),
|
||||||
|
new CountDistribution(2, 0.3),
|
||||||
|
new CountDistribution(3, 0.2)
|
||||||
|
),
|
||||||
|
List.of(new CountDistribution(5, 4),
|
||||||
|
new CountDistribution(10, 3),
|
||||||
|
new CountDistribution(15, 2),
|
||||||
|
new CountDistribution(20, 1)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
));
|
||||||
|
|
||||||
|
private final DemoDataParameters parameters;
|
||||||
|
|
||||||
|
DemoData(DemoDataParameters parameters) {
|
||||||
|
this.parameters = parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DemoDataParameters getParameters() {
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CountDistribution(int count, double weight) {}
|
||||||
|
|
||||||
|
public record DemoDataParameters(List<String> locations,
|
||||||
|
List<String> requiredSkills,
|
||||||
|
List<String> optionalSkills,
|
||||||
|
int daysInSchedule,
|
||||||
|
int employeeCount,
|
||||||
|
List<CountDistribution> optionalSkillDistribution,
|
||||||
|
List<CountDistribution> shiftCountDistribution,
|
||||||
|
List<CountDistribution> availabilityCountDistribution,
|
||||||
|
int randomSeed) {}
|
||||||
|
|
||||||
|
private static final String[] FIRST_NAMES = { "Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay" };
|
||||||
|
private static final String[] LAST_NAMES = { "Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt" };
|
||||||
|
private static final Duration SHIFT_LENGTH = Duration.ofHours(8);
|
||||||
|
private static final LocalTime MORNING_SHIFT_START_TIME = LocalTime.of(6, 0);
|
||||||
|
private static final LocalTime DAY_SHIFT_START_TIME = LocalTime.of(9, 0);
|
||||||
|
private static final LocalTime AFTERNOON_SHIFT_START_TIME = LocalTime.of(14, 0);
|
||||||
|
private static final LocalTime NIGHT_SHIFT_START_TIME = LocalTime.of(22, 0);
|
||||||
|
|
||||||
|
static final LocalTime[][] SHIFT_START_TIMES_COMBOS = {
|
||||||
|
{ MORNING_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME },
|
||||||
|
{ MORNING_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME, NIGHT_SHIFT_START_TIME },
|
||||||
|
{ MORNING_SHIFT_START_TIME, DAY_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME, NIGHT_SHIFT_START_TIME },
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, List<LocalTime>> locationToShiftStartTimeListMap = new HashMap<>();
|
||||||
|
|
||||||
|
public EmployeeSchedule generateDemoData(DemoData demoData) {
|
||||||
|
return generateDemoData(demoData.getParameters());
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmployeeSchedule generateDemoData(DemoDataParameters parameters) {
|
||||||
|
EmployeeSchedule employeeSchedule = new EmployeeSchedule();
|
||||||
|
|
||||||
|
LocalDate startDate = LocalDate.now().with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY));
|
||||||
|
|
||||||
|
Random random = new Random(parameters.randomSeed);
|
||||||
|
|
||||||
|
int shiftTemplateIndex = 0;
|
||||||
|
for (String location : parameters.locations) {
|
||||||
|
locationToShiftStartTimeListMap.put(location, List.of(SHIFT_START_TIMES_COMBOS[shiftTemplateIndex]));
|
||||||
|
shiftTemplateIndex = (shiftTemplateIndex + 1) % SHIFT_START_TIMES_COMBOS.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> namePermutations = joinAllCombinations(FIRST_NAMES, LAST_NAMES);
|
||||||
|
Collections.shuffle(namePermutations, random);
|
||||||
|
|
||||||
|
List<Employee> employees = new ArrayList<>();
|
||||||
|
for (int i = 0; i < parameters.employeeCount; i++) {
|
||||||
|
Set<String> skills = pickSubset(parameters.optionalSkills, random, parameters.optionalSkillDistribution);
|
||||||
|
skills.add(pickRandom(parameters.requiredSkills, random));
|
||||||
|
Employee employee = new Employee(namePermutations.get(i), skills, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>());
|
||||||
|
employees.add(employee);
|
||||||
|
}
|
||||||
|
employeeSchedule.setEmployees(employees);
|
||||||
|
|
||||||
|
List<Shift> shifts = new LinkedList<>();
|
||||||
|
for (int i = 0; i < parameters.daysInSchedule; i++) {
|
||||||
|
Set<Employee> employeesWithAvailabilitiesOnDay = pickSubset(employees, random,
|
||||||
|
parameters.availabilityCountDistribution);
|
||||||
|
LocalDate date = startDate.plusDays(i);
|
||||||
|
for (Employee employee : employeesWithAvailabilitiesOnDay) {
|
||||||
|
switch (random.nextInt(3)) {
|
||||||
|
case 0 -> employee.getUnavailableDates().add(date);
|
||||||
|
case 1 -> employee.getUndesiredDates().add(date);
|
||||||
|
case 2 -> employee.getDesiredDates().add(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shifts.addAll(generateShiftsForDay(parameters, date, random));
|
||||||
|
}
|
||||||
|
AtomicInteger countShift = new AtomicInteger();
|
||||||
|
shifts.forEach(s -> s.setId(Integer.toString(countShift.getAndIncrement())));
|
||||||
|
employeeSchedule.setShifts(shifts);
|
||||||
|
|
||||||
|
return employeeSchedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Shift> generateShiftsForDay(DemoDataParameters parameters, LocalDate date, Random random) {
|
||||||
|
List<Shift> shifts = new LinkedList<>();
|
||||||
|
for (String location : parameters.locations) {
|
||||||
|
List<LocalTime> shiftStartTimes = locationToShiftStartTimeListMap.get(location);
|
||||||
|
for (LocalTime shiftStartTime : shiftStartTimes) {
|
||||||
|
LocalDateTime shiftStartDateTime = date.atTime(shiftStartTime);
|
||||||
|
LocalDateTime shiftEndDateTime = shiftStartDateTime.plus(SHIFT_LENGTH);
|
||||||
|
shifts.addAll(generateShiftForTimeslot(parameters, shiftStartDateTime, shiftEndDateTime, location, random));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shifts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Shift> generateShiftForTimeslot(DemoDataParameters parameters,
|
||||||
|
LocalDateTime timeslotStart, LocalDateTime timeslotEnd, String location,
|
||||||
|
Random random) {
|
||||||
|
var shiftCount = pickCount(random, parameters.shiftCountDistribution);
|
||||||
|
|
||||||
|
List<Shift> shifts = new LinkedList<>();
|
||||||
|
for (int i = 0; i < shiftCount; i++) {
|
||||||
|
String requiredSkill;
|
||||||
|
if (random.nextBoolean()) {
|
||||||
|
requiredSkill = pickRandom(parameters.requiredSkills, random);
|
||||||
|
} else {
|
||||||
|
requiredSkill = pickRandom(parameters.optionalSkills, random);
|
||||||
|
}
|
||||||
|
shifts.add(new Shift(timeslotStart, timeslotEnd, location, requiredSkill));
|
||||||
|
}
|
||||||
|
return shifts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T pickRandom(List<T> source, Random random) {
|
||||||
|
return source.get(random.nextInt(source.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int pickCount(Random random, List<CountDistribution> countDistribution) {
|
||||||
|
double probabilitySum = 0;
|
||||||
|
for (var possibility : countDistribution) {
|
||||||
|
probabilitySum += possibility.weight;
|
||||||
|
}
|
||||||
|
var choice = random.nextDouble(probabilitySum);
|
||||||
|
int numOfItems = 0;
|
||||||
|
while (choice >= countDistribution.get(numOfItems).weight) {
|
||||||
|
choice -= countDistribution.get(numOfItems).weight;
|
||||||
|
numOfItems++;
|
||||||
|
}
|
||||||
|
return countDistribution.get(numOfItems).count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> Set<T> pickSubset(List<T> sourceSet, Random random, List<CountDistribution> countDistribution) {
|
||||||
|
var count = pickCount(random, countDistribution);
|
||||||
|
List<T> items = new ArrayList<>(sourceSet);
|
||||||
|
Collections.shuffle(items, random);
|
||||||
|
return new HashSet<>(items.subList(0, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> joinAllCombinations(String[]... partArrays) {
|
||||||
|
int size = 1;
|
||||||
|
for (String[] partArray : partArrays) {
|
||||||
|
size *= partArray.length;
|
||||||
|
}
|
||||||
|
List<String> out = new ArrayList<>(size);
|
||||||
|
for (int i = 0; i < size; i++) {
|
||||||
|
StringBuilder item = new StringBuilder();
|
||||||
|
int sizePerIncrement = 1;
|
||||||
|
for (String[] partArray : partArrays) {
|
||||||
|
item.append(' ');
|
||||||
|
item.append(partArray[(i / sizePerIncrement) % partArray.length]);
|
||||||
|
sizePerIncrement *= partArray.length;
|
||||||
|
}
|
||||||
|
item.delete(0, 1);
|
||||||
|
out.add(item.toString());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package org.acme.employeescheduling.rest;
|
||||||
|
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
import org.acme.employeescheduling.domain.EmployeeSchedule;
|
||||||
|
import org.acme.employeescheduling.rest.DemoDataGenerator.DemoData;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||||
|
|
||||||
|
@Tag(name = "Demo data", description = "Timefold-provided demo employee schedule data.")
|
||||||
|
@Path("demo-data")
|
||||||
|
public class EmployeeScheduleDemoResource {
|
||||||
|
|
||||||
|
private final DemoDataGenerator dataGenerator;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public EmployeeScheduleDemoResource(DemoDataGenerator dataGenerator) {
|
||||||
|
this.dataGenerator = dataGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
@APIResponses(value = {
|
||||||
|
@APIResponse(responseCode = "200", description = "List of demo data represented as IDs.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = DemoData.class, type = SchemaType.ARRAY))) })
|
||||||
|
@Operation(summary = "List demo data.")
|
||||||
|
@GET
|
||||||
|
public DemoData[] list() {
|
||||||
|
return DemoData.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
@APIResponses(value = {
|
||||||
|
@APIResponse(responseCode = "200", description = "Unsolved demo schedule.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = EmployeeSchedule.class)))})
|
||||||
|
@Operation(summary = "Find an unsolved demo schedule by ID.")
|
||||||
|
@GET
|
||||||
|
@Path("/{demoDataId}")
|
||||||
|
public Response generate(@PathParam("demoDataId") DemoData demoData) {
|
||||||
|
return Response.ok(dataGenerator.generateDemoData(demoData)).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,201 @@
|
|||||||
|
package org.acme.employeescheduling.rest;
|
||||||
|
|
||||||
|
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;
|
||||||
|
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.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;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
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
|
||||||
|
public EmployeeScheduleResource(SolverManager<EmployeeSchedule, String> solverManager,
|
||||||
|
SolutionManager<EmployeeSchedule, HardSoftBigDecimalScore> solutionManager) {
|
||||||
|
this.solverManager = solverManager;
|
||||||
|
this.solutionManager = solutionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "List the job IDs of all submitted schedules.")
|
||||||
|
@APIResponses(value = {
|
||||||
|
@APIResponse(responseCode = "200", description = "List of all job IDs.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(type = SchemaType.ARRAY, implementation = String.class))) })
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public Collection<String> list() {
|
||||||
|
return jobIdToJob.keySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Submit a schedule to start solving as soon as CPU resources are available.")
|
||||||
|
@APIResponses(value = {
|
||||||
|
@APIResponse(responseCode = "202",
|
||||||
|
description = "The job ID. Use that ID to get the solution with the other methods.",
|
||||||
|
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(implementation = String.class))) })
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Submit a schedule to analyze its score.")
|
||||||
|
@APIResponses(value = {
|
||||||
|
@APIResponse(responseCode = "202",
|
||||||
|
description = "Resulting score analysis, optionally without constraint matches.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = ScoreAnalysis.class))) })
|
||||||
|
@PUT
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Path("analyze")
|
||||||
|
public ScoreAnalysis<HardSoftBigDecimalScore> analyze(EmployeeSchedule problem,
|
||||||
|
@QueryParam("fetchPolicy") ScoreAnalysisFetchPolicy fetchPolicy) {
|
||||||
|
return fetchPolicy == null ? solutionManager.analyze(problem) : solutionManager.analyze(problem, fetchPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get the solution and score for a given job ID. This is the best solution so far, as it might still be running or not even started.")
|
||||||
|
@APIResponses(value = {
|
||||||
|
@APIResponse(responseCode = "200", description = "The best solution of the schedule so far.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = EmployeeSchedule.class))),
|
||||||
|
@APIResponse(responseCode = "404", description = "No schedule found.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = ErrorInfo.class))),
|
||||||
|
@APIResponse(responseCode = "500", description = "Exception during solving a schedule.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = ErrorInfo.class)))
|
||||||
|
})
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Path("{jobId}")
|
||||||
|
public EmployeeSchedule getEmployeeSchedule(
|
||||||
|
@Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) {
|
||||||
|
EmployeeSchedule schedule = getEmployeeScheduleAndCheckForExceptions(jobId);
|
||||||
|
SolverStatus solverStatus = solverManager.getSolverStatus(jobId);
|
||||||
|
schedule.setSolverStatus(solverStatus);
|
||||||
|
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 = {
|
||||||
|
@APIResponse(responseCode = "200", description = "The best solution of the schedule so far.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = EmployeeSchedule.class))),
|
||||||
|
@APIResponse(responseCode = "404", description = "No schedule found.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = ErrorInfo.class))),
|
||||||
|
@APIResponse(responseCode = "500", description = "Exception during solving a schedule.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = ErrorInfo.class)))
|
||||||
|
})
|
||||||
|
@DELETE
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get the schedule status and score for a given job ID.")
|
||||||
|
@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))),
|
||||||
|
@APIResponse(responseCode = "404", description = "No schedule found.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = ErrorInfo.class))),
|
||||||
|
@APIResponse(responseCode = "500", description = "Exception during solving a schedule.",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = ErrorInfo.class)))
|
||||||
|
})
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Path("{jobId}/status")
|
||||||
|
public EmployeeSchedule 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package org.acme.employeescheduling.rest.exception;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
public class EmployeeScheduleSolverException extends RuntimeException {
|
||||||
|
|
||||||
|
private final String jobId;
|
||||||
|
|
||||||
|
private final Response.Status status;
|
||||||
|
|
||||||
|
public EmployeeScheduleSolverException(String jobId, Response.Status status, String message) {
|
||||||
|
super(message);
|
||||||
|
this.jobId = jobId;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmployeeScheduleSolverException(String jobId, Throwable cause) {
|
||||||
|
super(cause.getMessage(), cause);
|
||||||
|
this.jobId = jobId;
|
||||||
|
this.status = Response.Status.INTERNAL_SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getJobId() {
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Response.Status getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package org.acme.employeescheduling.rest.exception;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import jakarta.ws.rs.ext.ExceptionMapper;
|
||||||
|
import jakarta.ws.rs.ext.Provider;
|
||||||
|
|
||||||
|
@Provider
|
||||||
|
public class EmployeeScheduleSolverExceptionMapper implements ExceptionMapper<EmployeeScheduleSolverException> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Response toResponse(EmployeeScheduleSolverException exception) {
|
||||||
|
return Response
|
||||||
|
.status(exception.getStatus())
|
||||||
|
.type(MediaType.APPLICATION_JSON)
|
||||||
|
.entity(new ErrorInfo(exception.getJobId(), exception.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
package org.acme.employeescheduling.rest.exception;
|
||||||
|
|
||||||
|
public record ErrorInfo(String jobId, String message) {
|
||||||
|
}
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
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.LocalDateTime;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
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.ConstraintCollectors;
|
||||||
|
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.common.LoadBalance;
|
||||||
|
|
||||||
|
import org.acme.employeescheduling.domain.Employee;
|
||||||
|
import org.acme.employeescheduling.domain.Shift;
|
||||||
|
|
||||||
|
public class EmployeeSchedulingConstraintProvider implements ConstraintProvider {
|
||||||
|
|
||||||
|
private static int getMinuteOverlap(Shift shift1, Shift shift2) {
|
||||||
|
// The overlap of two timeslot occurs in the range common to both timeslots.
|
||||||
|
// Both timeslots are active after the higher of their two start times,
|
||||||
|
// and before the lower of their two end times.
|
||||||
|
LocalDateTime shift1Start = shift1.getStart();
|
||||||
|
LocalDateTime shift1End = shift1.getEnd();
|
||||||
|
LocalDateTime shift2Start = shift2.getStart();
|
||||||
|
LocalDateTime shift2End = shift2.getEnd();
|
||||||
|
return (int) Duration.between((shift1Start.isAfter(shift2Start)) ? shift1Start : shift2Start,
|
||||||
|
(shift1End.isBefore(shift2End)) ? shift1End : shift2End).toMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
|
||||||
|
return new Constraint[] {
|
||||||
|
// Hard constraints
|
||||||
|
requiredSkill(constraintFactory),
|
||||||
|
noOverlappingShifts(constraintFactory),
|
||||||
|
atLeast10HoursBetweenTwoShifts(constraintFactory),
|
||||||
|
oneShiftPerDay(constraintFactory),
|
||||||
|
unavailableEmployee(constraintFactory),
|
||||||
|
// Soft constraints
|
||||||
|
undesiredDayForEmployee(constraintFactory),
|
||||||
|
desiredDayForEmployee(constraintFactory),
|
||||||
|
balanceEmployeeShiftAssignments(constraintFactory)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
Constraint atLeast10HoursBetweenTwoShifts(ConstraintFactory constraintFactory) {
|
||||||
|
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(HardSoftBigDecimalScore.ONE_HARD,
|
||||||
|
(firstShift, secondShift) -> {
|
||||||
|
int breakLength = (int) Duration.between(firstShift.getEnd(), secondShift.getStart()).toMinutes();
|
||||||
|
return (10 * 60) - breakLength;
|
||||||
|
})
|
||||||
|
.asConstraint("At least 10 hours between 2 shifts");
|
||||||
|
}
|
||||||
|
|
||||||
|
Constraint oneShiftPerDay(ConstraintFactory constraintFactory) {
|
||||||
|
return constraintFactory.forEachUniquePair(Shift.class, equal(Shift::getEmployee),
|
||||||
|
equal(shift -> shift.getStart().toLocalDate()))
|
||||||
|
.penalize(HardSoftBigDecimalScore.ONE_HARD)
|
||||||
|
.asConstraint("Max one shift per day");
|
||||||
|
}
|
||||||
|
|
||||||
|
Constraint unavailableEmployee(ConstraintFactory constraintFactory) {
|
||||||
|
return constraintFactory.forEach(Shift.class)
|
||||||
|
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
|
||||||
|
.flattenLast(Employee::getUnavailableDates)
|
||||||
|
.filter(Shift::isOverlappingWithDate)
|
||||||
|
.penalize(HardSoftBigDecimalScore.ONE_HARD, Shift::getOverlappingDurationInMinutes)
|
||||||
|
.asConstraint("Unavailable employee");
|
||||||
|
}
|
||||||
|
|
||||||
|
Constraint undesiredDayForEmployee(ConstraintFactory constraintFactory) {
|
||||||
|
return constraintFactory.forEach(Shift.class)
|
||||||
|
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
|
||||||
|
.flattenLast(Employee::getUndesiredDates)
|
||||||
|
.filter(Shift::isOverlappingWithDate)
|
||||||
|
.penalize(HardSoftBigDecimalScore.ONE_SOFT, Shift::getOverlappingDurationInMinutes)
|
||||||
|
.asConstraint("Undesired day for employee");
|
||||||
|
}
|
||||||
|
|
||||||
|
Constraint desiredDayForEmployee(ConstraintFactory constraintFactory) {
|
||||||
|
return constraintFactory.forEach(Shift.class)
|
||||||
|
.join(Employee.class, equal(Shift::getEmployee, Function.identity()))
|
||||||
|
.flattenLast(Employee::getDesiredDates)
|
||||||
|
.filter(Shift::isOverlappingWithDate)
|
||||||
|
.reward(HardSoftBigDecimalScore.ONE_SOFT, Shift::getOverlappingDurationInMinutes)
|
||||||
|
.asConstraint("Desired day for employee");
|
||||||
|
}
|
||||||
|
|
||||||
|
Constraint balanceEmployeeShiftAssignments(ConstraintFactory constraintFactory) {
|
||||||
|
return constraintFactory.forEach(Shift.class)
|
||||||
|
.groupBy(Shift::getEmployee, ConstraintCollectors.count())
|
||||||
|
.complement(Employee.class, e -> 0) // Include all employees which are not assigned to any shift.c
|
||||||
|
.groupBy(ConstraintCollectors.loadBalance((employee, shiftCount) -> employee,
|
||||||
|
(employee, shiftCount) -> shiftCount))
|
||||||
|
.penalizeBigDecimal(HardSoftBigDecimalScore.ONE_SOFT, LoadBalance::unfairness)
|
||||||
|
.asConstraint("Balance employee shift assignments");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
496
src/main/resources/META-INF/resources/app.js
Normal file
496
src/main/resources/META-INF/resources/app.js
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
let autoRefreshIntervalId = null;
|
||||||
|
const zoomMin = 2 * 1000 * 60 * 60 * 24 // 2 day in milliseconds
|
||||||
|
const zoomMax = 4 * 7 * 1000 * 60 * 60 * 24 // 4 weeks in milliseconds
|
||||||
|
|
||||||
|
const UNAVAILABLE_COLOR = '#ef2929' // Tango Scarlet Red
|
||||||
|
const UNDESIRED_COLOR = '#f57900' // Tango Orange
|
||||||
|
const DESIRED_COLOR = '#73d216' // Tango Chameleon
|
||||||
|
|
||||||
|
let demoDataId = null;
|
||||||
|
let scheduleId = null;
|
||||||
|
let loadedSchedule = null;
|
||||||
|
|
||||||
|
const byEmployeePanel = document.getElementById("byEmployeePanel");
|
||||||
|
const byEmployeeTimelineOptions = {
|
||||||
|
timeAxis: {scale: "hour", step: 6},
|
||||||
|
orientation: {axis: "top"},
|
||||||
|
stack: false,
|
||||||
|
xss: {disabled: true}, // Items are XSS safe through JQuery
|
||||||
|
zoomMin: zoomMin,
|
||||||
|
zoomMax: zoomMax,
|
||||||
|
};
|
||||||
|
let byEmployeeGroupDataSet = new vis.DataSet();
|
||||||
|
let byEmployeeItemDataSet = new vis.DataSet();
|
||||||
|
let byEmployeeTimeline = new vis.Timeline(byEmployeePanel, byEmployeeItemDataSet, byEmployeeGroupDataSet, byEmployeeTimelineOptions);
|
||||||
|
|
||||||
|
const byLocationPanel = document.getElementById("byLocationPanel");
|
||||||
|
const byLocationTimelineOptions = {
|
||||||
|
timeAxis: {scale: "hour", step: 6},
|
||||||
|
orientation: {axis: "top"},
|
||||||
|
xss: {disabled: true}, // Items are XSS safe through JQuery
|
||||||
|
zoomMin: zoomMin,
|
||||||
|
zoomMax: zoomMax,
|
||||||
|
};
|
||||||
|
let byLocationGroupDataSet = new vis.DataSet();
|
||||||
|
let byLocationItemDataSet = new vis.DataSet();
|
||||||
|
let byLocationTimeline = new vis.Timeline(byLocationPanel, byLocationItemDataSet, byLocationGroupDataSet, byLocationTimelineOptions);
|
||||||
|
|
||||||
|
let windowStart = JSJoda.LocalDate.now().toString();
|
||||||
|
let windowEnd = JSJoda.LocalDate.parse(windowStart).plusDays(7).toString();
|
||||||
|
|
||||||
|
$(document).ready(function () {
|
||||||
|
replaceQuickstartTimefoldAutoHeaderFooter();
|
||||||
|
|
||||||
|
$("#solveButton").click(function () {
|
||||||
|
solve();
|
||||||
|
});
|
||||||
|
$("#stopSolvingButton").click(function () {
|
||||||
|
stopSolving();
|
||||||
|
});
|
||||||
|
$("#analyzeButton").click(function () {
|
||||||
|
analyze();
|
||||||
|
});
|
||||||
|
// HACK to allow vis-timeline to work within Bootstrap tabs
|
||||||
|
$("#byEmployeeTab").on('shown.bs.tab', function (event) {
|
||||||
|
byEmployeeTimeline.redraw();
|
||||||
|
})
|
||||||
|
$("#byLocationTab").on('shown.bs.tab', function (event) {
|
||||||
|
byLocationTimeline.redraw();
|
||||||
|
})
|
||||||
|
|
||||||
|
setupAjax();
|
||||||
|
fetchDemoData();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupAjax() {
|
||||||
|
$.ajaxSetup({
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Extend jQuery to support $.put() and $.delete()
|
||||||
|
jQuery.each(["put", "delete"], function (i, method) {
|
||||||
|
jQuery[method] = function (url, data, callback, type) {
|
||||||
|
if (jQuery.isFunction(data)) {
|
||||||
|
type = type || callback;
|
||||||
|
callback = data;
|
||||||
|
data = undefined;
|
||||||
|
}
|
||||||
|
return jQuery.ajax({
|
||||||
|
url: url,
|
||||||
|
type: method,
|
||||||
|
dataType: type,
|
||||||
|
data: data,
|
||||||
|
success: callback
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchDemoData() {
|
||||||
|
$.get("/demo-data", function (data) {
|
||||||
|
data.forEach(item => {
|
||||||
|
$("#testDataButton").append($('<a id="' + item + 'TestData" class="dropdown-item" href="#">' + item + '</a>'));
|
||||||
|
$("#" + item + "TestData").click(function () {
|
||||||
|
switchDataDropDownItemActive(item);
|
||||||
|
scheduleId = null;
|
||||||
|
demoDataId = item;
|
||||||
|
|
||||||
|
refreshSchedule();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
demoDataId = data[0];
|
||||||
|
switchDataDropDownItemActive(demoDataId);
|
||||||
|
refreshSchedule();
|
||||||
|
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||||
|
// disable this page as there is no data
|
||||||
|
let $demo = $("#demo");
|
||||||
|
$demo.empty();
|
||||||
|
$demo.html("<h1><p align=\"center\">No test data available</p></h1>")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchDataDropDownItemActive(newItem) {
|
||||||
|
activeCssClass = "active";
|
||||||
|
$("#testDataButton > a." + activeCssClass).removeClass(activeCssClass);
|
||||||
|
$("#" + newItem + "TestData").addClass(activeCssClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShiftColor(shift, employee) {
|
||||||
|
const shiftStart = JSJoda.LocalDateTime.parse(shift.start);
|
||||||
|
const shiftStartDateString = shiftStart.toLocalDate().toString();
|
||||||
|
const shiftEnd = JSJoda.LocalDateTime.parse(shift.end);
|
||||||
|
const shiftEndDateString = shiftEnd.toLocalDate().toString();
|
||||||
|
if (employee.unavailableDates.includes(shiftStartDateString) ||
|
||||||
|
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||||
|
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||||
|
employee.unavailableDates.includes(shiftEndDateString))) {
|
||||||
|
return UNAVAILABLE_COLOR
|
||||||
|
} else if (employee.undesiredDates.includes(shiftStartDateString) ||
|
||||||
|
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||||
|
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||||
|
employee.undesiredDates.includes(shiftEndDateString))) {
|
||||||
|
return UNDESIRED_COLOR
|
||||||
|
} else if (employee.desiredDates.includes(shiftStartDateString) ||
|
||||||
|
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||||
|
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||||
|
employee.desiredDates.includes(shiftEndDateString))) {
|
||||||
|
return DESIRED_COLOR
|
||||||
|
} else {
|
||||||
|
return " #729fcf"; // Tango Sky Blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSchedule() {
|
||||||
|
let path = "/schedules/" + scheduleId;
|
||||||
|
if (scheduleId === null) {
|
||||||
|
if (demoDataId === null) {
|
||||||
|
alert("Please select a test data set.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
path = "/demo-data/" + demoDataId;
|
||||||
|
}
|
||||||
|
$.getJSON(path, function (schedule) {
|
||||||
|
loadedSchedule = schedule;
|
||||||
|
renderSchedule(schedule);
|
||||||
|
})
|
||||||
|
.fail(function (xhr, ajaxOptions, thrownError) {
|
||||||
|
showError("Getting the schedule has failed.", xhr);
|
||||||
|
refreshSolvingButtons(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSchedule(schedule) {
|
||||||
|
refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING");
|
||||||
|
$("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score));
|
||||||
|
|
||||||
|
const unassignedShifts = $("#unassignedShifts");
|
||||||
|
const groups = [];
|
||||||
|
|
||||||
|
// Show only first 7 days of draft
|
||||||
|
const scheduleStart = schedule.shifts.map(shift => JSJoda.LocalDateTime.parse(shift.start).toLocalDate()).sort()[0].toString();
|
||||||
|
const scheduleEnd = JSJoda.LocalDate.parse(scheduleStart).plusDays(7).toString();
|
||||||
|
|
||||||
|
windowStart = scheduleStart;
|
||||||
|
windowEnd = scheduleEnd;
|
||||||
|
|
||||||
|
unassignedShifts.children().remove();
|
||||||
|
let unassignedShiftsCount = 0;
|
||||||
|
byEmployeeGroupDataSet.clear();
|
||||||
|
byLocationGroupDataSet.clear();
|
||||||
|
|
||||||
|
byEmployeeItemDataSet.clear();
|
||||||
|
byLocationItemDataSet.clear();
|
||||||
|
|
||||||
|
|
||||||
|
schedule.employees.forEach((employee, index) => {
|
||||||
|
const employeeGroupElement = $('<div class="card-body p-2"/>')
|
||||||
|
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||||
|
.append(employee.name))
|
||||||
|
.append($('<div/>')
|
||||||
|
.append($(employee.skills.map(skill => `<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${skill}</span>`).join(''))));
|
||||||
|
byEmployeeGroupDataSet.add({id: employee.name, content: employeeGroupElement.html()});
|
||||||
|
|
||||||
|
employee.unavailableDates.forEach((rawDate, dateIndex) => {
|
||||||
|
const date = JSJoda.LocalDate.parse(rawDate)
|
||||||
|
const start = date.atStartOfDay().toString();
|
||||||
|
const end = date.plusDays(1).atStartOfDay().toString();
|
||||||
|
const byEmployeeShiftElement = $(`<div/>`)
|
||||||
|
.append($(`<h5 class="card-title mb-1"/>`).text("Unavailable"));
|
||||||
|
byEmployeeItemDataSet.add({
|
||||||
|
id: "employee-" + index + "-unavailability-" + dateIndex, group: employee.name,
|
||||||
|
content: byEmployeeShiftElement.html(),
|
||||||
|
start: start, end: end,
|
||||||
|
type: "background",
|
||||||
|
style: "opacity: 0.5; background-color: " + UNAVAILABLE_COLOR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
employee.undesiredDates.forEach((rawDate, dateIndex) => {
|
||||||
|
const date = JSJoda.LocalDate.parse(rawDate)
|
||||||
|
const start = date.atStartOfDay().toString();
|
||||||
|
const end = date.plusDays(1).atStartOfDay().toString();
|
||||||
|
const byEmployeeShiftElement = $(`<div/>`)
|
||||||
|
.append($(`<h5 class="card-title mb-1"/>`).text("Undesired"));
|
||||||
|
byEmployeeItemDataSet.add({
|
||||||
|
id: "employee-" + index + "-undesired-" + dateIndex, group: employee.name,
|
||||||
|
content: byEmployeeShiftElement.html(),
|
||||||
|
start: start, end: end,
|
||||||
|
type: "background",
|
||||||
|
style: "opacity: 0.5; background-color: " + UNDESIRED_COLOR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
employee.desiredDates.forEach((rawDate, dateIndex) => {
|
||||||
|
const date = JSJoda.LocalDate.parse(rawDate)
|
||||||
|
const start = date.atStartOfDay().toString();
|
||||||
|
const end = date.plusDays(1).atStartOfDay().toString();
|
||||||
|
const byEmployeeShiftElement = $(`<div/>`)
|
||||||
|
.append($(`<h5 class="card-title mb-1"/>`).text("Desired"));
|
||||||
|
byEmployeeItemDataSet.add({
|
||||||
|
id: "employee-" + index + "-desired-" + dateIndex, group: employee.name,
|
||||||
|
content: byEmployeeShiftElement.html(),
|
||||||
|
start: start, end: end,
|
||||||
|
type: "background",
|
||||||
|
style: "opacity: 0.5; background-color: " + DESIRED_COLOR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
schedule.shifts.forEach((shift, index) => {
|
||||||
|
if (groups.indexOf(shift.location) === -1) {
|
||||||
|
groups.push(shift.location);
|
||||||
|
byLocationGroupDataSet.add({
|
||||||
|
id: shift.location,
|
||||||
|
content: shift.location,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shift.employee == null) {
|
||||||
|
unassignedShiftsCount++;
|
||||||
|
|
||||||
|
const byLocationShiftElement = $('<div class="card-body p-2"/>')
|
||||||
|
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||||
|
.append("Unassigned"))
|
||||||
|
.append($('<div/>')
|
||||||
|
.append($(`<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${shift.requiredSkill}</span>`)));
|
||||||
|
|
||||||
|
byLocationItemDataSet.add({
|
||||||
|
id: 'shift-' + index, group: shift.location,
|
||||||
|
content: byLocationShiftElement.html(),
|
||||||
|
start: shift.start, end: shift.end,
|
||||||
|
style: "background-color: #EF292999"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const skillColor = (shift.employee.skills.indexOf(shift.requiredSkill) === -1 ? '#ef2929' : '#8ae234');
|
||||||
|
const byEmployeeShiftElement = $('<div class="card-body p-2"/>')
|
||||||
|
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||||
|
.append(shift.location))
|
||||||
|
.append($('<div/>')
|
||||||
|
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
|
||||||
|
const byLocationShiftElement = $('<div class="card-body p-2"/>')
|
||||||
|
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||||
|
.append(shift.employee.name))
|
||||||
|
.append($('<div/>')
|
||||||
|
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
|
||||||
|
|
||||||
|
const shiftColor = getShiftColor(shift, shift.employee);
|
||||||
|
byEmployeeItemDataSet.add({
|
||||||
|
id: 'shift-' + index, group: shift.employee.name,
|
||||||
|
content: byEmployeeShiftElement.html(),
|
||||||
|
start: shift.start, end: shift.end,
|
||||||
|
style: "background-color: " + shiftColor
|
||||||
|
});
|
||||||
|
byLocationItemDataSet.add({
|
||||||
|
id: 'shift-' + index, group: shift.location,
|
||||||
|
content: byLocationShiftElement.html(),
|
||||||
|
start: shift.start, end: shift.end,
|
||||||
|
style: "background-color: " + shiftColor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
if (unassignedShiftsCount === 0) {
|
||||||
|
unassignedShifts.append($(`<p/>`).text(`There are no unassigned shifts.`));
|
||||||
|
} else {
|
||||||
|
unassignedShifts.append($(`<p/>`).text(`There are ${unassignedShiftsCount} unassigned shifts.`));
|
||||||
|
}
|
||||||
|
byEmployeeTimeline.setWindow(scheduleStart, scheduleEnd);
|
||||||
|
byLocationTimeline.setWindow(scheduleStart, scheduleEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function solve() {
|
||||||
|
$.post("/schedules", JSON.stringify(loadedSchedule), function (data) {
|
||||||
|
scheduleId = data;
|
||||||
|
refreshSolvingButtons(true);
|
||||||
|
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||||
|
showError("Start solving failed.", xhr);
|
||||||
|
refreshSolvingButtons(false);
|
||||||
|
},
|
||||||
|
"text");
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyze() {
|
||||||
|
new bootstrap.Modal("#scoreAnalysisModal").show()
|
||||||
|
const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
|
||||||
|
scoreAnalysisModalContent.children().remove();
|
||||||
|
if (loadedSchedule.score == null) {
|
||||||
|
scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button.");
|
||||||
|
} else {
|
||||||
|
$('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
|
||||||
|
$.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) {
|
||||||
|
let constraints = scoreAnalysis.constraints;
|
||||||
|
constraints.sort((a, b) => {
|
||||||
|
let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
|
||||||
|
if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
|
||||||
|
if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
|
||||||
|
if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
|
||||||
|
if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
|
||||||
|
if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
|
||||||
|
if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
|
||||||
|
|
||||||
|
return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
constraints.map((e) => {
|
||||||
|
let components = getScoreComponents(e.weight);
|
||||||
|
e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
|
||||||
|
e.weight = components[e.type];
|
||||||
|
let scores = getScoreComponents(e.score);
|
||||||
|
e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
|
||||||
|
});
|
||||||
|
scoreAnalysis.constraints = constraints;
|
||||||
|
|
||||||
|
scoreAnalysisModalContent.children().remove();
|
||||||
|
scoreAnalysisModalContent.text("");
|
||||||
|
|
||||||
|
const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
|
||||||
|
const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
|
||||||
|
.append($(`<th></th>`))
|
||||||
|
.append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
|
||||||
|
.append($(`<th>Type</th>`))
|
||||||
|
.append($(`<th># Matches</th>`))
|
||||||
|
.append($(`<th>Weight</th>`))
|
||||||
|
.append($(`<th>Score</th>`))
|
||||||
|
.append($(`<th></th>`)));
|
||||||
|
analysisTable.append(analysisTHead);
|
||||||
|
const analysisTBody = $(`<tbody/>`)
|
||||||
|
$.each(scoreAnalysis.constraints, (index, constraintAnalysis) => {
|
||||||
|
let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
|
||||||
|
if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
|
||||||
|
|
||||||
|
let row = $(`<tr/>`);
|
||||||
|
row.append($(`<td/>`).html(icon))
|
||||||
|
.append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
|
||||||
|
.append($(`<td/>`).text(constraintAnalysis.type))
|
||||||
|
.append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
|
||||||
|
.append($(`<td/>`).text(constraintAnalysis.weight))
|
||||||
|
.append($(`<td/>`).text(constraintAnalysis.implicitScore));
|
||||||
|
analysisTBody.append(row);
|
||||||
|
row.append($(`<td/>`));
|
||||||
|
});
|
||||||
|
analysisTable.append(analysisTBody);
|
||||||
|
scoreAnalysisModalContent.append(analysisTable);
|
||||||
|
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||||
|
showError("Analyze failed.", xhr);
|
||||||
|
}, "text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScoreComponents(score) {
|
||||||
|
let components = {hard: 0, medium: 0, soft: 0};
|
||||||
|
|
||||||
|
$.each([...score.matchAll(/(-?\d*(\.\d+)?)(hard|medium|soft)/g)], (i, parts) => {
|
||||||
|
components[parts[3]] = parseFloat(parts[1], 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
return components;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSolvingButtons(solving) {
|
||||||
|
if (solving) {
|
||||||
|
$("#solveButton").hide();
|
||||||
|
$("#stopSolvingButton").show();
|
||||||
|
if (autoRefreshIntervalId == null) {
|
||||||
|
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$("#solveButton").show();
|
||||||
|
$("#stopSolvingButton").hide();
|
||||||
|
if (autoRefreshIntervalId != null) {
|
||||||
|
clearInterval(autoRefreshIntervalId);
|
||||||
|
autoRefreshIntervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSolvingButtons(solving) {
|
||||||
|
if (solving) {
|
||||||
|
$("#solveButton").hide();
|
||||||
|
$("#stopSolvingButton").show();
|
||||||
|
if (autoRefreshIntervalId == null) {
|
||||||
|
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$("#solveButton").show();
|
||||||
|
$("#stopSolvingButton").hide();
|
||||||
|
if (autoRefreshIntervalId != null) {
|
||||||
|
clearInterval(autoRefreshIntervalId);
|
||||||
|
autoRefreshIntervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSolving() {
|
||||||
|
$.delete(`/schedules/${scheduleId}`, function () {
|
||||||
|
refreshSolvingButtons(false);
|
||||||
|
refreshSchedule();
|
||||||
|
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||||
|
showError("Stop solving failed.", xhr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceQuickstartTimefoldAutoHeaderFooter() {
|
||||||
|
const timefoldHeader = $("header#timefold-auto-header");
|
||||||
|
if (timefoldHeader != null) {
|
||||||
|
timefoldHeader.addClass("bg-black")
|
||||||
|
timefoldHeader.append(
|
||||||
|
$(`<div class="container-fluid">
|
||||||
|
<nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
|
||||||
|
<a class="navbar-brand" href="https://timefold.ai">
|
||||||
|
<img src="/webjars/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
<li class="nav-item active" id="navUIItem">
|
||||||
|
<button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button">Demo UI</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" id="navRestItem">
|
||||||
|
<button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button">Guide</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" id="navOpenApiItem">
|
||||||
|
<button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button">REST API</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
Data
|
||||||
|
</button>
|
||||||
|
<div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timefoldFooter = $("footer#timefold-auto-footer");
|
||||||
|
if (timefoldFooter != null) {
|
||||||
|
timefoldFooter.append(
|
||||||
|
$(`<footer class="bg-black text-white-50">
|
||||||
|
<div class="container">
|
||||||
|
<div class="hstack gap-3 p-4">
|
||||||
|
<div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div>
|
||||||
|
<div class="vr"></div>
|
||||||
|
<div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
|
||||||
|
<div class="vr"></div>
|
||||||
|
<div><a class="text-white" href="https://github.com/TimefoldAI/timefold-quickstarts">Code</a></div>
|
||||||
|
<div class="vr"></div>
|
||||||
|
<div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>`));
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/main/resources/META-INF/resources/index.html
Normal file
161
src/main/resources/META-INF/resources/index.html
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
|
||||||
|
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||||
|
<title>Employee scheduling - Timefold Solver on Quarkus</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
|
||||||
|
integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.min.css"/>
|
||||||
|
<link rel="stylesheet" href="/webjars/font-awesome/css/all.css"/>
|
||||||
|
<link rel="stylesheet" href="/webjars/timefold/css/timefold-webui.css"/>
|
||||||
|
<style>
|
||||||
|
.vis-time-axis .vis-grid.vis-saturday,
|
||||||
|
.vis-time-axis .vis-grid.vis-sunday {
|
||||||
|
background: #D3D7CFFF;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="icon" href="/webjars/timefold/img/timefold-favicon.svg" type="image/svg+xml">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header id="timefold-auto-header">
|
||||||
|
<!-- Filled in by app.js -->
|
||||||
|
</header>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div id="demo" class="tab-pane fade show active container-fluid">
|
||||||
|
<div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite"
|
||||||
|
aria-atomic="true">
|
||||||
|
<div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
|
||||||
|
</div>
|
||||||
|
<h1>Employee scheduling solver</h1>
|
||||||
|
<p>Generate the optimal schedule for your employees.</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<button id="solveButton" type="button" class="btn btn-success">
|
||||||
|
<span class="fas fa-play"></span> Solve
|
||||||
|
</button>
|
||||||
|
<button id="stopSolvingButton" type="button" class="btn btn-danger">
|
||||||
|
<span class="fas fa-stop"></span> Stop solving
|
||||||
|
</button>
|
||||||
|
<span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
|
||||||
|
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
|
||||||
|
<button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
|
||||||
|
<span class="fas fa-question"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="float-end">
|
||||||
|
<ul class="nav nav-pills" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="byLocationTab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#byLocationPanel" type="button" role="tab"
|
||||||
|
aria-controls="byLocationPanel" aria-selected="true">By location
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="byEmployeeTab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#byEmployeePanel" type="button" role="tab"
|
||||||
|
aria-controls="byEmployeePanel" aria-selected="false">By employee
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="byLocationPanel" role="tabpanel"
|
||||||
|
aria-labelledby="byLocationTab">
|
||||||
|
<div id="locationVisualization"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="byEmployeePanel" role="tabpanel" aria-labelledby="byEmployeeTab">
|
||||||
|
<div id="employeeVisualization"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rest" class="tab-pane fade container-fluid">
|
||||||
|
<h1>REST API Guide</h1>
|
||||||
|
|
||||||
|
<h2>Employee Scheduling solver integration via cURL</h2>
|
||||||
|
|
||||||
|
<h3>1. Download demo data</h3>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl1')">Copy</button>
|
||||||
|
<code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data/SMALL -o sample.json</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h3>2. Post the sample data for solving</h3>
|
||||||
|
<p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl2')">Copy</button>
|
||||||
|
<code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h3>3. Get the current status and score</h3>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl3')">Copy</button>
|
||||||
|
<code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h3>4. Get the complete solution</h3>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl4')">Copy</button>
|
||||||
|
<code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h3>5. Fetch the analysis of the solution</h3>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl5')">Copy</button>
|
||||||
|
<code id="curl5">curl -X PUT -H 'Content-Type:application/json' http://localhost:8080/schedules/analyze -d@solution.json</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h3>5. Terminate solving early</h3>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl5')">Copy</button>
|
||||||
|
<code id="curl6">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{id}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="openapi" class="tab-pane fade container-fluid">
|
||||||
|
<h1>REST API Reference</h1>
|
||||||
|
<div class="ratio ratio-1x1">
|
||||||
|
<!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
|
||||||
|
<iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer id="timefold-auto-footer"></footer>
|
||||||
|
<div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1"
|
||||||
|
aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span
|
||||||
|
id="scoreAnalysisScoreLabel"></span></h1>
|
||||||
|
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="scoreAnalysisModalContent">
|
||||||
|
<!-- Filled in by app.js -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/webjars/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/webjars/jquery/jquery.min.js"></script>
|
||||||
|
<script src="/webjars/js-joda/dist/js-joda.min.js"></script>
|
||||||
|
<script src="/webjars/timefold/js/timefold-webui.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
|
||||||
|
integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
src/main/resources/application.properties
Normal file
43
src/main/resources/application.properties
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
########################
|
||||||
|
# Timefold Solver properties
|
||||||
|
########################
|
||||||
|
|
||||||
|
# The solver runs for 30 seconds. To run for 5 minutes use "5m" and for 2 hours use "2h".
|
||||||
|
quarkus.timefold.solver.termination.spent-limit=30s
|
||||||
|
|
||||||
|
# To change how many solvers to run in parallel
|
||||||
|
# timefold.solver-manager.parallel-solver-count=4
|
||||||
|
|
||||||
|
# Temporary comment this out to detect bugs in your code (lowers performance)
|
||||||
|
# quarkus.timefold.solver.environment-mode=FULL_ASSERT
|
||||||
|
|
||||||
|
# Temporary comment this out to return a feasible solution as soon as possible
|
||||||
|
# quarkus.timefold.solver.termination.best-score-limit=0hard/*soft
|
||||||
|
|
||||||
|
# To see what Timefold is doing, turn on DEBUG or TRACE logging.
|
||||||
|
quarkus.log.category."ai.timefold.solver".level=INFO
|
||||||
|
%test.quarkus.log.category."ai.timefold.solver".level=INFO
|
||||||
|
%prod.quarkus.log.category."ai.timefold.solver".level=INFO
|
||||||
|
|
||||||
|
# XML file for power tweaking, defaults to solverConfig.xml (directly under src/main/resources)
|
||||||
|
# quarkus.timefold.solver-config-xml=org/.../maintenanceScheduleSolverConfig.xml
|
||||||
|
|
||||||
|
########################
|
||||||
|
# Timefold Solver Enterprise properties
|
||||||
|
########################
|
||||||
|
|
||||||
|
# To run increase CPU cores usage per solver
|
||||||
|
%enterprise.quarkus.timefold.solver.move-thread-count=AUTO
|
||||||
|
|
||||||
|
########################
|
||||||
|
# Native build properties
|
||||||
|
########################
|
||||||
|
|
||||||
|
# Enable Swagger UI also in the native mode
|
||||||
|
quarkus.swagger-ui.always-include=true
|
||||||
|
|
||||||
|
########################
|
||||||
|
# Test overrides
|
||||||
|
########################
|
||||||
|
|
||||||
|
%test.quarkus.timefold.solver.termination.spent-limit=10s
|
||||||
1
target/build-metrics.json
Normal file
1
target/build-metrics.json
Normal file
File diff suppressed because one or more lines are too long
496
target/classes/META-INF/resources/app.js
Normal file
496
target/classes/META-INF/resources/app.js
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
let autoRefreshIntervalId = null;
|
||||||
|
const zoomMin = 2 * 1000 * 60 * 60 * 24 // 2 day in milliseconds
|
||||||
|
const zoomMax = 4 * 7 * 1000 * 60 * 60 * 24 // 4 weeks in milliseconds
|
||||||
|
|
||||||
|
const UNAVAILABLE_COLOR = '#ef2929' // Tango Scarlet Red
|
||||||
|
const UNDESIRED_COLOR = '#f57900' // Tango Orange
|
||||||
|
const DESIRED_COLOR = '#73d216' // Tango Chameleon
|
||||||
|
|
||||||
|
let demoDataId = null;
|
||||||
|
let scheduleId = null;
|
||||||
|
let loadedSchedule = null;
|
||||||
|
|
||||||
|
const byEmployeePanel = document.getElementById("byEmployeePanel");
|
||||||
|
const byEmployeeTimelineOptions = {
|
||||||
|
timeAxis: {scale: "hour", step: 6},
|
||||||
|
orientation: {axis: "top"},
|
||||||
|
stack: false,
|
||||||
|
xss: {disabled: true}, // Items are XSS safe through JQuery
|
||||||
|
zoomMin: zoomMin,
|
||||||
|
zoomMax: zoomMax,
|
||||||
|
};
|
||||||
|
let byEmployeeGroupDataSet = new vis.DataSet();
|
||||||
|
let byEmployeeItemDataSet = new vis.DataSet();
|
||||||
|
let byEmployeeTimeline = new vis.Timeline(byEmployeePanel, byEmployeeItemDataSet, byEmployeeGroupDataSet, byEmployeeTimelineOptions);
|
||||||
|
|
||||||
|
const byLocationPanel = document.getElementById("byLocationPanel");
|
||||||
|
const byLocationTimelineOptions = {
|
||||||
|
timeAxis: {scale: "hour", step: 6},
|
||||||
|
orientation: {axis: "top"},
|
||||||
|
xss: {disabled: true}, // Items are XSS safe through JQuery
|
||||||
|
zoomMin: zoomMin,
|
||||||
|
zoomMax: zoomMax,
|
||||||
|
};
|
||||||
|
let byLocationGroupDataSet = new vis.DataSet();
|
||||||
|
let byLocationItemDataSet = new vis.DataSet();
|
||||||
|
let byLocationTimeline = new vis.Timeline(byLocationPanel, byLocationItemDataSet, byLocationGroupDataSet, byLocationTimelineOptions);
|
||||||
|
|
||||||
|
let windowStart = JSJoda.LocalDate.now().toString();
|
||||||
|
let windowEnd = JSJoda.LocalDate.parse(windowStart).plusDays(7).toString();
|
||||||
|
|
||||||
|
$(document).ready(function () {
|
||||||
|
replaceQuickstartTimefoldAutoHeaderFooter();
|
||||||
|
|
||||||
|
$("#solveButton").click(function () {
|
||||||
|
solve();
|
||||||
|
});
|
||||||
|
$("#stopSolvingButton").click(function () {
|
||||||
|
stopSolving();
|
||||||
|
});
|
||||||
|
$("#analyzeButton").click(function () {
|
||||||
|
analyze();
|
||||||
|
});
|
||||||
|
// HACK to allow vis-timeline to work within Bootstrap tabs
|
||||||
|
$("#byEmployeeTab").on('shown.bs.tab', function (event) {
|
||||||
|
byEmployeeTimeline.redraw();
|
||||||
|
})
|
||||||
|
$("#byLocationTab").on('shown.bs.tab', function (event) {
|
||||||
|
byLocationTimeline.redraw();
|
||||||
|
})
|
||||||
|
|
||||||
|
setupAjax();
|
||||||
|
fetchDemoData();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupAjax() {
|
||||||
|
$.ajaxSetup({
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Extend jQuery to support $.put() and $.delete()
|
||||||
|
jQuery.each(["put", "delete"], function (i, method) {
|
||||||
|
jQuery[method] = function (url, data, callback, type) {
|
||||||
|
if (jQuery.isFunction(data)) {
|
||||||
|
type = type || callback;
|
||||||
|
callback = data;
|
||||||
|
data = undefined;
|
||||||
|
}
|
||||||
|
return jQuery.ajax({
|
||||||
|
url: url,
|
||||||
|
type: method,
|
||||||
|
dataType: type,
|
||||||
|
data: data,
|
||||||
|
success: callback
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchDemoData() {
|
||||||
|
$.get("/demo-data", function (data) {
|
||||||
|
data.forEach(item => {
|
||||||
|
$("#testDataButton").append($('<a id="' + item + 'TestData" class="dropdown-item" href="#">' + item + '</a>'));
|
||||||
|
$("#" + item + "TestData").click(function () {
|
||||||
|
switchDataDropDownItemActive(item);
|
||||||
|
scheduleId = null;
|
||||||
|
demoDataId = item;
|
||||||
|
|
||||||
|
refreshSchedule();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
demoDataId = data[0];
|
||||||
|
switchDataDropDownItemActive(demoDataId);
|
||||||
|
refreshSchedule();
|
||||||
|
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||||
|
// disable this page as there is no data
|
||||||
|
let $demo = $("#demo");
|
||||||
|
$demo.empty();
|
||||||
|
$demo.html("<h1><p align=\"center\">No test data available</p></h1>")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchDataDropDownItemActive(newItem) {
|
||||||
|
activeCssClass = "active";
|
||||||
|
$("#testDataButton > a." + activeCssClass).removeClass(activeCssClass);
|
||||||
|
$("#" + newItem + "TestData").addClass(activeCssClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShiftColor(shift, employee) {
|
||||||
|
const shiftStart = JSJoda.LocalDateTime.parse(shift.start);
|
||||||
|
const shiftStartDateString = shiftStart.toLocalDate().toString();
|
||||||
|
const shiftEnd = JSJoda.LocalDateTime.parse(shift.end);
|
||||||
|
const shiftEndDateString = shiftEnd.toLocalDate().toString();
|
||||||
|
if (employee.unavailableDates.includes(shiftStartDateString) ||
|
||||||
|
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||||
|
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||||
|
employee.unavailableDates.includes(shiftEndDateString))) {
|
||||||
|
return UNAVAILABLE_COLOR
|
||||||
|
} else if (employee.undesiredDates.includes(shiftStartDateString) ||
|
||||||
|
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||||
|
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||||
|
employee.undesiredDates.includes(shiftEndDateString))) {
|
||||||
|
return UNDESIRED_COLOR
|
||||||
|
} else if (employee.desiredDates.includes(shiftStartDateString) ||
|
||||||
|
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
||||||
|
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
||||||
|
employee.desiredDates.includes(shiftEndDateString))) {
|
||||||
|
return DESIRED_COLOR
|
||||||
|
} else {
|
||||||
|
return " #729fcf"; // Tango Sky Blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSchedule() {
|
||||||
|
let path = "/schedules/" + scheduleId;
|
||||||
|
if (scheduleId === null) {
|
||||||
|
if (demoDataId === null) {
|
||||||
|
alert("Please select a test data set.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
path = "/demo-data/" + demoDataId;
|
||||||
|
}
|
||||||
|
$.getJSON(path, function (schedule) {
|
||||||
|
loadedSchedule = schedule;
|
||||||
|
renderSchedule(schedule);
|
||||||
|
})
|
||||||
|
.fail(function (xhr, ajaxOptions, thrownError) {
|
||||||
|
showError("Getting the schedule has failed.", xhr);
|
||||||
|
refreshSolvingButtons(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSchedule(schedule) {
|
||||||
|
refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING");
|
||||||
|
$("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score));
|
||||||
|
|
||||||
|
const unassignedShifts = $("#unassignedShifts");
|
||||||
|
const groups = [];
|
||||||
|
|
||||||
|
// Show only first 7 days of draft
|
||||||
|
const scheduleStart = schedule.shifts.map(shift => JSJoda.LocalDateTime.parse(shift.start).toLocalDate()).sort()[0].toString();
|
||||||
|
const scheduleEnd = JSJoda.LocalDate.parse(scheduleStart).plusDays(7).toString();
|
||||||
|
|
||||||
|
windowStart = scheduleStart;
|
||||||
|
windowEnd = scheduleEnd;
|
||||||
|
|
||||||
|
unassignedShifts.children().remove();
|
||||||
|
let unassignedShiftsCount = 0;
|
||||||
|
byEmployeeGroupDataSet.clear();
|
||||||
|
byLocationGroupDataSet.clear();
|
||||||
|
|
||||||
|
byEmployeeItemDataSet.clear();
|
||||||
|
byLocationItemDataSet.clear();
|
||||||
|
|
||||||
|
|
||||||
|
schedule.employees.forEach((employee, index) => {
|
||||||
|
const employeeGroupElement = $('<div class="card-body p-2"/>')
|
||||||
|
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||||
|
.append(employee.name))
|
||||||
|
.append($('<div/>')
|
||||||
|
.append($(employee.skills.map(skill => `<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${skill}</span>`).join(''))));
|
||||||
|
byEmployeeGroupDataSet.add({id: employee.name, content: employeeGroupElement.html()});
|
||||||
|
|
||||||
|
employee.unavailableDates.forEach((rawDate, dateIndex) => {
|
||||||
|
const date = JSJoda.LocalDate.parse(rawDate)
|
||||||
|
const start = date.atStartOfDay().toString();
|
||||||
|
const end = date.plusDays(1).atStartOfDay().toString();
|
||||||
|
const byEmployeeShiftElement = $(`<div/>`)
|
||||||
|
.append($(`<h5 class="card-title mb-1"/>`).text("Unavailable"));
|
||||||
|
byEmployeeItemDataSet.add({
|
||||||
|
id: "employee-" + index + "-unavailability-" + dateIndex, group: employee.name,
|
||||||
|
content: byEmployeeShiftElement.html(),
|
||||||
|
start: start, end: end,
|
||||||
|
type: "background",
|
||||||
|
style: "opacity: 0.5; background-color: " + UNAVAILABLE_COLOR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
employee.undesiredDates.forEach((rawDate, dateIndex) => {
|
||||||
|
const date = JSJoda.LocalDate.parse(rawDate)
|
||||||
|
const start = date.atStartOfDay().toString();
|
||||||
|
const end = date.plusDays(1).atStartOfDay().toString();
|
||||||
|
const byEmployeeShiftElement = $(`<div/>`)
|
||||||
|
.append($(`<h5 class="card-title mb-1"/>`).text("Undesired"));
|
||||||
|
byEmployeeItemDataSet.add({
|
||||||
|
id: "employee-" + index + "-undesired-" + dateIndex, group: employee.name,
|
||||||
|
content: byEmployeeShiftElement.html(),
|
||||||
|
start: start, end: end,
|
||||||
|
type: "background",
|
||||||
|
style: "opacity: 0.5; background-color: " + UNDESIRED_COLOR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
employee.desiredDates.forEach((rawDate, dateIndex) => {
|
||||||
|
const date = JSJoda.LocalDate.parse(rawDate)
|
||||||
|
const start = date.atStartOfDay().toString();
|
||||||
|
const end = date.plusDays(1).atStartOfDay().toString();
|
||||||
|
const byEmployeeShiftElement = $(`<div/>`)
|
||||||
|
.append($(`<h5 class="card-title mb-1"/>`).text("Desired"));
|
||||||
|
byEmployeeItemDataSet.add({
|
||||||
|
id: "employee-" + index + "-desired-" + dateIndex, group: employee.name,
|
||||||
|
content: byEmployeeShiftElement.html(),
|
||||||
|
start: start, end: end,
|
||||||
|
type: "background",
|
||||||
|
style: "opacity: 0.5; background-color: " + DESIRED_COLOR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
schedule.shifts.forEach((shift, index) => {
|
||||||
|
if (groups.indexOf(shift.location) === -1) {
|
||||||
|
groups.push(shift.location);
|
||||||
|
byLocationGroupDataSet.add({
|
||||||
|
id: shift.location,
|
||||||
|
content: shift.location,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shift.employee == null) {
|
||||||
|
unassignedShiftsCount++;
|
||||||
|
|
||||||
|
const byLocationShiftElement = $('<div class="card-body p-2"/>')
|
||||||
|
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||||
|
.append("Unassigned"))
|
||||||
|
.append($('<div/>')
|
||||||
|
.append($(`<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${shift.requiredSkill}</span>`)));
|
||||||
|
|
||||||
|
byLocationItemDataSet.add({
|
||||||
|
id: 'shift-' + index, group: shift.location,
|
||||||
|
content: byLocationShiftElement.html(),
|
||||||
|
start: shift.start, end: shift.end,
|
||||||
|
style: "background-color: #EF292999"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const skillColor = (shift.employee.skills.indexOf(shift.requiredSkill) === -1 ? '#ef2929' : '#8ae234');
|
||||||
|
const byEmployeeShiftElement = $('<div class="card-body p-2"/>')
|
||||||
|
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||||
|
.append(shift.location))
|
||||||
|
.append($('<div/>')
|
||||||
|
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
|
||||||
|
const byLocationShiftElement = $('<div class="card-body p-2"/>')
|
||||||
|
.append($(`<h5 class="card-title mb-2"/>)`)
|
||||||
|
.append(shift.employee.name))
|
||||||
|
.append($('<div/>')
|
||||||
|
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
|
||||||
|
|
||||||
|
const shiftColor = getShiftColor(shift, shift.employee);
|
||||||
|
byEmployeeItemDataSet.add({
|
||||||
|
id: 'shift-' + index, group: shift.employee.name,
|
||||||
|
content: byEmployeeShiftElement.html(),
|
||||||
|
start: shift.start, end: shift.end,
|
||||||
|
style: "background-color: " + shiftColor
|
||||||
|
});
|
||||||
|
byLocationItemDataSet.add({
|
||||||
|
id: 'shift-' + index, group: shift.location,
|
||||||
|
content: byLocationShiftElement.html(),
|
||||||
|
start: shift.start, end: shift.end,
|
||||||
|
style: "background-color: " + shiftColor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
if (unassignedShiftsCount === 0) {
|
||||||
|
unassignedShifts.append($(`<p/>`).text(`There are no unassigned shifts.`));
|
||||||
|
} else {
|
||||||
|
unassignedShifts.append($(`<p/>`).text(`There are ${unassignedShiftsCount} unassigned shifts.`));
|
||||||
|
}
|
||||||
|
byEmployeeTimeline.setWindow(scheduleStart, scheduleEnd);
|
||||||
|
byLocationTimeline.setWindow(scheduleStart, scheduleEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function solve() {
|
||||||
|
$.post("/schedules", JSON.stringify(loadedSchedule), function (data) {
|
||||||
|
scheduleId = data;
|
||||||
|
refreshSolvingButtons(true);
|
||||||
|
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||||
|
showError("Start solving failed.", xhr);
|
||||||
|
refreshSolvingButtons(false);
|
||||||
|
},
|
||||||
|
"text");
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyze() {
|
||||||
|
new bootstrap.Modal("#scoreAnalysisModal").show()
|
||||||
|
const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
|
||||||
|
scoreAnalysisModalContent.children().remove();
|
||||||
|
if (loadedSchedule.score == null) {
|
||||||
|
scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button.");
|
||||||
|
} else {
|
||||||
|
$('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
|
||||||
|
$.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) {
|
||||||
|
let constraints = scoreAnalysis.constraints;
|
||||||
|
constraints.sort((a, b) => {
|
||||||
|
let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
|
||||||
|
if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
|
||||||
|
if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
|
||||||
|
if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
|
||||||
|
if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
|
||||||
|
if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
|
||||||
|
if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
|
||||||
|
|
||||||
|
return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
constraints.map((e) => {
|
||||||
|
let components = getScoreComponents(e.weight);
|
||||||
|
e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
|
||||||
|
e.weight = components[e.type];
|
||||||
|
let scores = getScoreComponents(e.score);
|
||||||
|
e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
|
||||||
|
});
|
||||||
|
scoreAnalysis.constraints = constraints;
|
||||||
|
|
||||||
|
scoreAnalysisModalContent.children().remove();
|
||||||
|
scoreAnalysisModalContent.text("");
|
||||||
|
|
||||||
|
const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
|
||||||
|
const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
|
||||||
|
.append($(`<th></th>`))
|
||||||
|
.append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
|
||||||
|
.append($(`<th>Type</th>`))
|
||||||
|
.append($(`<th># Matches</th>`))
|
||||||
|
.append($(`<th>Weight</th>`))
|
||||||
|
.append($(`<th>Score</th>`))
|
||||||
|
.append($(`<th></th>`)));
|
||||||
|
analysisTable.append(analysisTHead);
|
||||||
|
const analysisTBody = $(`<tbody/>`)
|
||||||
|
$.each(scoreAnalysis.constraints, (index, constraintAnalysis) => {
|
||||||
|
let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
|
||||||
|
if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
|
||||||
|
|
||||||
|
let row = $(`<tr/>`);
|
||||||
|
row.append($(`<td/>`).html(icon))
|
||||||
|
.append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
|
||||||
|
.append($(`<td/>`).text(constraintAnalysis.type))
|
||||||
|
.append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
|
||||||
|
.append($(`<td/>`).text(constraintAnalysis.weight))
|
||||||
|
.append($(`<td/>`).text(constraintAnalysis.implicitScore));
|
||||||
|
analysisTBody.append(row);
|
||||||
|
row.append($(`<td/>`));
|
||||||
|
});
|
||||||
|
analysisTable.append(analysisTBody);
|
||||||
|
scoreAnalysisModalContent.append(analysisTable);
|
||||||
|
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||||
|
showError("Analyze failed.", xhr);
|
||||||
|
}, "text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScoreComponents(score) {
|
||||||
|
let components = {hard: 0, medium: 0, soft: 0};
|
||||||
|
|
||||||
|
$.each([...score.matchAll(/(-?\d*(\.\d+)?)(hard|medium|soft)/g)], (i, parts) => {
|
||||||
|
components[parts[3]] = parseFloat(parts[1], 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
return components;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSolvingButtons(solving) {
|
||||||
|
if (solving) {
|
||||||
|
$("#solveButton").hide();
|
||||||
|
$("#stopSolvingButton").show();
|
||||||
|
if (autoRefreshIntervalId == null) {
|
||||||
|
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$("#solveButton").show();
|
||||||
|
$("#stopSolvingButton").hide();
|
||||||
|
if (autoRefreshIntervalId != null) {
|
||||||
|
clearInterval(autoRefreshIntervalId);
|
||||||
|
autoRefreshIntervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSolvingButtons(solving) {
|
||||||
|
if (solving) {
|
||||||
|
$("#solveButton").hide();
|
||||||
|
$("#stopSolvingButton").show();
|
||||||
|
if (autoRefreshIntervalId == null) {
|
||||||
|
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$("#solveButton").show();
|
||||||
|
$("#stopSolvingButton").hide();
|
||||||
|
if (autoRefreshIntervalId != null) {
|
||||||
|
clearInterval(autoRefreshIntervalId);
|
||||||
|
autoRefreshIntervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSolving() {
|
||||||
|
$.delete(`/schedules/${scheduleId}`, function () {
|
||||||
|
refreshSolvingButtons(false);
|
||||||
|
refreshSchedule();
|
||||||
|
}).fail(function (xhr, ajaxOptions, thrownError) {
|
||||||
|
showError("Stop solving failed.", xhr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceQuickstartTimefoldAutoHeaderFooter() {
|
||||||
|
const timefoldHeader = $("header#timefold-auto-header");
|
||||||
|
if (timefoldHeader != null) {
|
||||||
|
timefoldHeader.addClass("bg-black")
|
||||||
|
timefoldHeader.append(
|
||||||
|
$(`<div class="container-fluid">
|
||||||
|
<nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
|
||||||
|
<a class="navbar-brand" href="https://timefold.ai">
|
||||||
|
<img src="/webjars/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
<li class="nav-item active" id="navUIItem">
|
||||||
|
<button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button">Demo UI</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" id="navRestItem">
|
||||||
|
<button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button">Guide</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" id="navOpenApiItem">
|
||||||
|
<button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button">REST API</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
Data
|
||||||
|
</button>
|
||||||
|
<div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timefoldFooter = $("footer#timefold-auto-footer");
|
||||||
|
if (timefoldFooter != null) {
|
||||||
|
timefoldFooter.append(
|
||||||
|
$(`<footer class="bg-black text-white-50">
|
||||||
|
<div class="container">
|
||||||
|
<div class="hstack gap-3 p-4">
|
||||||
|
<div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div>
|
||||||
|
<div class="vr"></div>
|
||||||
|
<div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
|
||||||
|
<div class="vr"></div>
|
||||||
|
<div><a class="text-white" href="https://github.com/TimefoldAI/timefold-quickstarts">Code</a></div>
|
||||||
|
<div class="vr"></div>
|
||||||
|
<div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>`));
|
||||||
|
}
|
||||||
|
}
|
||||||
161
target/classes/META-INF/resources/index.html
Normal file
161
target/classes/META-INF/resources/index.html
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
|
||||||
|
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||||
|
<title>Employee scheduling - Timefold Solver on Quarkus</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
|
||||||
|
integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.min.css"/>
|
||||||
|
<link rel="stylesheet" href="/webjars/font-awesome/css/all.css"/>
|
||||||
|
<link rel="stylesheet" href="/webjars/timefold/css/timefold-webui.css"/>
|
||||||
|
<style>
|
||||||
|
.vis-time-axis .vis-grid.vis-saturday,
|
||||||
|
.vis-time-axis .vis-grid.vis-sunday {
|
||||||
|
background: #D3D7CFFF;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="icon" href="/webjars/timefold/img/timefold-favicon.svg" type="image/svg+xml">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header id="timefold-auto-header">
|
||||||
|
<!-- Filled in by app.js -->
|
||||||
|
</header>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div id="demo" class="tab-pane fade show active container-fluid">
|
||||||
|
<div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite"
|
||||||
|
aria-atomic="true">
|
||||||
|
<div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
|
||||||
|
</div>
|
||||||
|
<h1>Employee scheduling solver</h1>
|
||||||
|
<p>Generate the optimal schedule for your employees.</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<button id="solveButton" type="button" class="btn btn-success">
|
||||||
|
<span class="fas fa-play"></span> Solve
|
||||||
|
</button>
|
||||||
|
<button id="stopSolvingButton" type="button" class="btn btn-danger">
|
||||||
|
<span class="fas fa-stop"></span> Stop solving
|
||||||
|
</button>
|
||||||
|
<span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
|
||||||
|
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
|
||||||
|
<button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
|
||||||
|
<span class="fas fa-question"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="float-end">
|
||||||
|
<ul class="nav nav-pills" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="byLocationTab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#byLocationPanel" type="button" role="tab"
|
||||||
|
aria-controls="byLocationPanel" aria-selected="true">By location
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="byEmployeeTab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#byEmployeePanel" type="button" role="tab"
|
||||||
|
aria-controls="byEmployeePanel" aria-selected="false">By employee
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="byLocationPanel" role="tabpanel"
|
||||||
|
aria-labelledby="byLocationTab">
|
||||||
|
<div id="locationVisualization"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="byEmployeePanel" role="tabpanel" aria-labelledby="byEmployeeTab">
|
||||||
|
<div id="employeeVisualization"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rest" class="tab-pane fade container-fluid">
|
||||||
|
<h1>REST API Guide</h1>
|
||||||
|
|
||||||
|
<h2>Employee Scheduling solver integration via cURL</h2>
|
||||||
|
|
||||||
|
<h3>1. Download demo data</h3>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl1')">Copy</button>
|
||||||
|
<code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data/SMALL -o sample.json</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h3>2. Post the sample data for solving</h3>
|
||||||
|
<p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl2')">Copy</button>
|
||||||
|
<code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h3>3. Get the current status and score</h3>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl3')">Copy</button>
|
||||||
|
<code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h3>4. Get the complete solution</h3>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl4')">Copy</button>
|
||||||
|
<code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h3>5. Fetch the analysis of the solution</h3>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl5')">Copy</button>
|
||||||
|
<code id="curl5">curl -X PUT -H 'Content-Type:application/json' http://localhost:8080/schedules/analyze -d@solution.json</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h3>5. Terminate solving early</h3>
|
||||||
|
<pre>
|
||||||
|
<button class="btn btn-outline-dark btn-sm float-end"
|
||||||
|
onclick="copyTextToClipboard('curl5')">Copy</button>
|
||||||
|
<code id="curl6">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{id}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="openapi" class="tab-pane fade container-fluid">
|
||||||
|
<h1>REST API Reference</h1>
|
||||||
|
<div class="ratio ratio-1x1">
|
||||||
|
<!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
|
||||||
|
<iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer id="timefold-auto-footer"></footer>
|
||||||
|
<div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1"
|
||||||
|
aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span
|
||||||
|
id="scoreAnalysisScoreLabel"></span></h1>
|
||||||
|
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="scoreAnalysisModalContent">
|
||||||
|
<!-- Filled in by app.js -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/webjars/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/webjars/jquery/jquery.min.js"></script>
|
||||||
|
<script src="/webjars/js-joda/dist/js-joda.min.js"></script>
|
||||||
|
<script src="/webjars/timefold/js/timefold-webui.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
|
||||||
|
integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
target/classes/application.properties
Normal file
43
target/classes/application.properties
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
########################
|
||||||
|
# Timefold Solver properties
|
||||||
|
########################
|
||||||
|
|
||||||
|
# The solver runs for 30 seconds. To run for 5 minutes use "5m" and for 2 hours use "2h".
|
||||||
|
quarkus.timefold.solver.termination.spent-limit=30s
|
||||||
|
|
||||||
|
# To change how many solvers to run in parallel
|
||||||
|
# timefold.solver-manager.parallel-solver-count=4
|
||||||
|
|
||||||
|
# Temporary comment this out to detect bugs in your code (lowers performance)
|
||||||
|
# quarkus.timefold.solver.environment-mode=FULL_ASSERT
|
||||||
|
|
||||||
|
# Temporary comment this out to return a feasible solution as soon as possible
|
||||||
|
# quarkus.timefold.solver.termination.best-score-limit=0hard/*soft
|
||||||
|
|
||||||
|
# To see what Timefold is doing, turn on DEBUG or TRACE logging.
|
||||||
|
quarkus.log.category."ai.timefold.solver".level=INFO
|
||||||
|
%test.quarkus.log.category."ai.timefold.solver".level=INFO
|
||||||
|
%prod.quarkus.log.category."ai.timefold.solver".level=INFO
|
||||||
|
|
||||||
|
# XML file for power tweaking, defaults to solverConfig.xml (directly under src/main/resources)
|
||||||
|
# quarkus.timefold.solver-config-xml=org/.../maintenanceScheduleSolverConfig.xml
|
||||||
|
|
||||||
|
########################
|
||||||
|
# Timefold Solver Enterprise properties
|
||||||
|
########################
|
||||||
|
|
||||||
|
# To run increase CPU cores usage per solver
|
||||||
|
%enterprise.quarkus.timefold.solver.move-thread-count=AUTO
|
||||||
|
|
||||||
|
########################
|
||||||
|
# Native build properties
|
||||||
|
########################
|
||||||
|
|
||||||
|
# Enable Swagger UI also in the native mode
|
||||||
|
quarkus.swagger-ui.always-include=true
|
||||||
|
|
||||||
|
########################
|
||||||
|
# Test overrides
|
||||||
|
########################
|
||||||
|
|
||||||
|
%test.quarkus.timefold.solver.termination.spent-limit=10s
|
||||||
BIN
target/classes/org/acme/employeescheduling/domain/Employee.class
Normal file
BIN
target/classes/org/acme/employeescheduling/domain/Employee.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
target/classes/org/acme/employeescheduling/domain/Shift.class
Normal file
BIN
target/classes/org/acme/employeescheduling/domain/Shift.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
target/employee-scheduling-dev.jar
Normal file
BIN
target/employee-scheduling-dev.jar
Normal file
Binary file not shown.
@ -0,0 +1,14 @@
|
|||||||
|
org/acme/employeescheduling/domain/EmployeeSchedule.class
|
||||||
|
org/acme/employeescheduling/rest/DemoDataGenerator$CountDistribution.class
|
||||||
|
org/acme/employeescheduling/rest/DemoDataGenerator$DemoDataParameters.class
|
||||||
|
org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverException.class
|
||||||
|
org/acme/employeescheduling/rest/EmployeeScheduleDemoResource.class
|
||||||
|
org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProvider.class
|
||||||
|
org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverExceptionMapper.class
|
||||||
|
org/acme/employeescheduling/rest/EmployeeScheduleResource$Job.class
|
||||||
|
org/acme/employeescheduling/rest/EmployeeScheduleResource.class
|
||||||
|
org/acme/employeescheduling/rest/DemoDataGenerator.class
|
||||||
|
org/acme/employeescheduling/domain/Shift.class
|
||||||
|
org/acme/employeescheduling/domain/Employee.class
|
||||||
|
org/acme/employeescheduling/rest/exception/ErrorInfo.class
|
||||||
|
org/acme/employeescheduling/rest/DemoDataGenerator$DemoData.class
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/main/java/org/acme/employeescheduling/domain/Employee.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/main/java/org/acme/employeescheduling/domain/EmployeeSchedule.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/main/java/org/acme/employeescheduling/domain/Shift.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/main/java/org/acme/employeescheduling/rest/DemoDataGenerator.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/main/java/org/acme/employeescheduling/rest/EmployeeScheduleDemoResource.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/main/java/org/acme/employeescheduling/rest/EmployeeScheduleResource.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/main/java/org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverException.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/main/java/org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverExceptionMapper.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/main/java/org/acme/employeescheduling/rest/exception/ErrorInfo.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/main/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProvider.java
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/domain/Employee.java
|
||||||
|
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/domain/EmployeeSchedule.java
|
||||||
|
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/domain/Shift.java
|
||||||
|
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/DemoDataGenerator.java
|
||||||
|
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/EmployeeScheduleDemoResource.java
|
||||||
|
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/EmployeeScheduleResource.java
|
||||||
|
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverException.java
|
||||||
|
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/exception/EmployeeScheduleSolverExceptionMapper.java
|
||||||
|
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/rest/exception/ErrorInfo.java
|
||||||
|
/home/virt/timefold-quickstarts/java/collect-sang/src/main/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProvider.java
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProviderTest.class
|
||||||
|
org/acme/employeescheduling/rest/EmployeeScheduleResourceTest.class
|
||||||
|
org/acme/employeescheduling/rest/EmployeeSchedulingEnvironmentTest.class
|
||||||
|
org/acme/employeescheduling/rest/EmployeeSchedulingResourceIT.class
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/test/java/org/acme/employeescheduling/rest/EmployeeScheduleResourceTest.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/test/java/org/acme/employeescheduling/rest/EmployeeSchedulingEnvironmentTest.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/test/java/org/acme/employeescheduling/rest/EmployeeSchedulingResourceIT.java
|
||||||
|
/home/virt/timefold-quickstarts/java/employee-scheduling/src/test/java/org/acme/employeescheduling/solver/EmployeeSchedulingConstraintProviderTest.java
|
||||||
BIN
target/quarkus/bootstrap/dev-app-model.dat
Normal file
BIN
target/quarkus/bootstrap/dev-app-model.dat
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
162
vi
Normal file
162
vi
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
{
|
||||||
|
"employees": [
|
||||||
|
{
|
||||||
|
"name": "Marie Dupont",
|
||||||
|
"skills": ["INFIRMIER", "PRELEVEMENT"],
|
||||||
|
"unavailableDates": ["2024-12-25", "2024-12-26"],
|
||||||
|
"undesiredDates": ["2024-12-24", "2024-12-31"],
|
||||||
|
"desiredDates": ["2024-12-20", "2024-12-21"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pierre Martin",
|
||||||
|
"skills": ["MEDECIN", "SUPERVISION"],
|
||||||
|
"unavailableDates": ["2024-12-30"],
|
||||||
|
"undesiredDates": ["2024-12-29"],
|
||||||
|
"desiredDates": ["2024-12-22", "2024-12-23"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sophie Bernard",
|
||||||
|
"skills": ["INFIRMIER", "ACCUEIL"],
|
||||||
|
"unavailableDates": [],
|
||||||
|
"undesiredDates": ["2024-12-25"],
|
||||||
|
"desiredDates": ["2024-12-24", "2024-12-28"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jean Leroy",
|
||||||
|
"skills": ["PRELEVEMENT", "TRANSPORT"],
|
||||||
|
"unavailableDates": ["2024-12-24", "2024-12-25"],
|
||||||
|
"undesiredDates": [],
|
||||||
|
"desiredDates": ["2024-12-27", "2024-12-30"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Anne Moreau",
|
||||||
|
"skills": ["MEDECIN", "INFIRMIER"],
|
||||||
|
"unavailableDates": ["2024-12-26"],
|
||||||
|
"undesiredDates": ["2024-12-31"],
|
||||||
|
"desiredDates": ["2024-12-21", "2024-12-29"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Luc Petit",
|
||||||
|
"skills": ["ACCUEIL", "TRANSPORT"],
|
||||||
|
"unavailableDates": [],
|
||||||
|
"undesiredDates": ["2024-12-20"],
|
||||||
|
"desiredDates": ["2024-12-25", "2024-12-26"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shifts": [
|
||||||
|
{
|
||||||
|
"id": "shift_001",
|
||||||
|
"start": "2024-12-20T08:00:00",
|
||||||
|
"end": "2024-12-20T16:00:00",
|
||||||
|
"location": "Centre de collecte - Toulouse",
|
||||||
|
"requiredSkill": "INFIRMIER",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_002",
|
||||||
|
"start": "2024-12-20T14:00:00",
|
||||||
|
"end": "2024-12-20T22:00:00",
|
||||||
|
"location": "Centre de collecte - Toulouse",
|
||||||
|
"requiredSkill": "PRELEVEMENT",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_003",
|
||||||
|
"start": "2024-12-21T06:00:00",
|
||||||
|
"end": "2024-12-21T14:00:00",
|
||||||
|
"location": "Hôpital Purpan",
|
||||||
|
"requiredSkill": "MEDECIN",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_004",
|
||||||
|
"start": "2024-12-21T08:00:00",
|
||||||
|
"end": "2024-12-21T12:00:00",
|
||||||
|
"location": "Centre de collecte - Colomiers",
|
||||||
|
"requiredSkill": "ACCUEIL",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_005",
|
||||||
|
"start": "2024-12-22T09:00:00",
|
||||||
|
"end": "2024-12-22T17:00:00",
|
||||||
|
"location": "Centre de collecte - Blagnac",
|
||||||
|
"requiredSkill": "INFIRMIER",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_006",
|
||||||
|
"start": "2024-12-22T13:00:00",
|
||||||
|
"end": "2024-12-22T18:00:00",
|
||||||
|
"location": "Transport mobile",
|
||||||
|
"requiredSkill": "TRANSPORT",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_007",
|
||||||
|
"start": "2024-12-23T07:00:00",
|
||||||
|
"end": "2024-12-23T15:00:00",
|
||||||
|
"location": "Hôpital Rangueil",
|
||||||
|
"requiredSkill": "MEDECIN",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_008",
|
||||||
|
"start": "2024-12-23T10:00:00",
|
||||||
|
"end": "2024-12-23T16:00:00",
|
||||||
|
"location": "Centre de collecte - Toulouse",
|
||||||
|
"requiredSkill": "PRELEVEMENT",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_009",
|
||||||
|
"start": "2024-12-24T08:00:00",
|
||||||
|
"end": "2024-12-24T14:00:00",
|
||||||
|
"location": "Centre de collecte - Colomiers",
|
||||||
|
"requiredSkill": "INFIRMIER",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_010",
|
||||||
|
"start": "2024-12-27T09:00:00",
|
||||||
|
"end": "2024-12-27T17:00:00",
|
||||||
|
"location": "Centre de collecte - Toulouse",
|
||||||
|
"requiredSkill": "SUPERVISION",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_011",
|
||||||
|
"start": "2024-12-28T08:00:00",
|
||||||
|
"end": "2024-12-28T16:00:00",
|
||||||
|
"location": "Centre de collecte - Blagnac",
|
||||||
|
"requiredSkill": "ACCUEIL",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_012",
|
||||||
|
"start": "2024-12-29T06:00:00",
|
||||||
|
"end": "2024-12-29T14:00:00",
|
||||||
|
"location": "Hôpital Purpan",
|
||||||
|
"requiredSkill": "MEDECIN",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_013",
|
||||||
|
"start": "2024-12-30T10:00:00",
|
||||||
|
"end": "2024-12-30T18:00:00",
|
||||||
|
"location": "Transport mobile",
|
||||||
|
"requiredSkill": "TRANSPORT",
|
||||||
|
"employee": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shift_014",
|
||||||
|
"start": "2024-12-31T08:00:00",
|
||||||
|
"end": "2024-12-31T16:00:00",
|
||||||
|
"location": "Centre de collecte - Toulouse",
|
||||||
|
"requiredSkill": "INFIRMIER",
|
||||||
|
"employee": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": null,
|
||||||
|
"solverStatus": null
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user