Commit
·
e510416
1
Parent(s):
1da6a88
Add Employee Scheduling Quickstart Demo
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitignore +27 -0
- Dockerfile +24 -0
- README.md +69 -2
- deploy/employee-scheduling/Chart.yaml +6 -0
- deploy/employee-scheduling/templates/_helpers.tpl +49 -0
- deploy/employee-scheduling/templates/deployment.yaml +70 -0
- deploy/employee-scheduling/templates/service.yaml +18 -0
- deploy/employee-scheduling/values.yaml +35 -0
- logging.conf +30 -0
- pyproject.toml +20 -0
- src/employee_scheduling/__init__.py +19 -0
- src/employee_scheduling/__pycache__/__init__.cpython-310.pyc +0 -0
- src/employee_scheduling/__pycache__/__init__.cpython-312.pyc +0 -0
- src/employee_scheduling/__pycache__/__init__.cpython-313.pyc +0 -0
- src/employee_scheduling/__pycache__/constraints.cpython-310.pyc +0 -0
- src/employee_scheduling/__pycache__/constraints.cpython-312.pyc +0 -0
- src/employee_scheduling/__pycache__/constraints.cpython-313.pyc +0 -0
- src/employee_scheduling/__pycache__/converters.cpython-310.pyc +0 -0
- src/employee_scheduling/__pycache__/converters.cpython-312.pyc +0 -0
- src/employee_scheduling/__pycache__/demo_data.cpython-310.pyc +0 -0
- src/employee_scheduling/__pycache__/demo_data.cpython-312.pyc +0 -0
- src/employee_scheduling/__pycache__/domain.cpython-310.pyc +0 -0
- src/employee_scheduling/__pycache__/domain.cpython-312.pyc +0 -0
- src/employee_scheduling/__pycache__/domain.cpython-313.pyc +0 -0
- src/employee_scheduling/__pycache__/json_serialization.cpython-310.pyc +0 -0
- src/employee_scheduling/__pycache__/json_serialization.cpython-312.pyc +0 -0
- src/employee_scheduling/__pycache__/rest_api.cpython-310.pyc +0 -0
- src/employee_scheduling/__pycache__/rest_api.cpython-312.pyc +0 -0
- src/employee_scheduling/__pycache__/solver.cpython-310.pyc +0 -0
- src/employee_scheduling/__pycache__/solver.cpython-312.pyc +0 -0
- src/employee_scheduling/constraints.py +213 -0
- src/employee_scheduling/converters.py +95 -0
- src/employee_scheduling/demo_data.py +228 -0
- src/employee_scheduling/domain.py +95 -0
- src/employee_scheduling/json_serialization.py +27 -0
- src/employee_scheduling/rest_api.py +56 -0
- src/employee_scheduling/solver.py +23 -0
- static/app.js +520 -0
- static/index.html +133 -0
- static/webjars/solverforge/css/solverforge-webui.css +68 -0
- static/webjars/solverforge/img/solverforge-favicon.svg +65 -0
- static/webjars/solverforge/img/solverforge-horizontal-white.svg +66 -0
- static/webjars/solverforge/img/solverforge-horizontal.svg +65 -0
- static/webjars/solverforge/img/solverforge-logo-stacked.svg +73 -0
- static/webjars/solverforge/js/solverforge-webui.js +142 -0
- tests/__pycache__/test_constraints.cpython-310-pytest-8.2.2.pyc +0 -0
- tests/__pycache__/test_constraints.cpython-311-pytest-8.2.2.pyc +0 -0
- tests/__pycache__/test_constraints.cpython-312-pytest-8.2.2.pyc +0 -0
- tests/__pycache__/test_feasible.cpython-310-pytest-8.2.2.pyc +0 -0
- tests/__pycache__/test_feasible.cpython-311-pytest-8.2.2.pyc +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
target
|
| 2 |
+
local
|
| 3 |
+
__pycache__
|
| 4 |
+
.pytest_cache
|
| 5 |
+
/*.egg-info
|
| 6 |
+
/**/dist
|
| 7 |
+
/**/*.egg-info
|
| 8 |
+
/**/*-stubs
|
| 9 |
+
.venv
|
| 10 |
+
|
| 11 |
+
# Eclipse, Netbeans and IntelliJ files
|
| 12 |
+
/.*
|
| 13 |
+
!/.github
|
| 14 |
+
!/.ci
|
| 15 |
+
!.gitignore
|
| 16 |
+
!.gitattributes
|
| 17 |
+
!/.mvn
|
| 18 |
+
/nbproject
|
| 19 |
+
*.ipr
|
| 20 |
+
*.iws
|
| 21 |
+
*.iml
|
| 22 |
+
|
| 23 |
+
# Repository wide ignore mac DS_Store files
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.code-workspace
|
| 26 |
+
CLAUDE.md
|
| 27 |
+
DOCUMENTATION_AUDIT.md
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.12 base image
|
| 2 |
+
FROM python:3.12
|
| 3 |
+
|
| 4 |
+
# Install JDK 21 (required for solverforge-legacy)
|
| 5 |
+
RUN apt-get update && \
|
| 6 |
+
apt-get install -y wget gnupg2 && \
|
| 7 |
+
wget -O- https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor > /usr/share/keyrings/adoptium-archive-keyring.gpg && \
|
| 8 |
+
echo "deb [signed-by=/usr/share/keyrings/adoptium-archive-keyring.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list && \
|
| 9 |
+
apt-get update && \
|
| 10 |
+
apt-get install -y temurin-21-jdk && \
|
| 11 |
+
apt-get clean && \
|
| 12 |
+
rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# Copy application files
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Install the application
|
| 18 |
+
RUN pip install --no-cache-dir -e .
|
| 19 |
+
|
| 20 |
+
# Expose port 8080
|
| 21 |
+
EXPOSE 8080
|
| 22 |
+
|
| 23 |
+
# Run the application
|
| 24 |
+
CMD ["run-app"]
|
README.md
CHANGED
|
@@ -1,12 +1,79 @@
|
|
| 1 |
---
|
| 2 |
-
title: Employee Scheduling Python
|
| 3 |
emoji: 👀
|
| 4 |
colorFrom: gray
|
| 5 |
colorTo: green
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: apache-2.0
|
| 9 |
short_description: SolverForge Quickstart for the Employee Scheduling problem
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Employee Scheduling (Python)
|
| 3 |
emoji: 👀
|
| 4 |
colorFrom: gray
|
| 5 |
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 8080
|
| 8 |
pinned: false
|
| 9 |
license: apache-2.0
|
| 10 |
short_description: SolverForge Quickstart for the Employee Scheduling problem
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Employee Scheduling (Python)
|
| 14 |
+
|
| 15 |
+
Schedule shifts to employees, accounting for employee availability and shift skill requirements.
|
| 16 |
+
|
| 17 |
+
- [Prerequisites](#prerequisites)
|
| 18 |
+
- [Run the application](#run-the-application)
|
| 19 |
+
- [Test the application](#test-the-application)
|
| 20 |
+
|
| 21 |
+
## Prerequisites
|
| 22 |
+
|
| 23 |
+
1. Install [Python 3.11 or 3.12](https://www.python.org/downloads/).
|
| 24 |
+
|
| 25 |
+
2. Install JDK 17+, for example with [Sdkman](https://sdkman.io):
|
| 26 |
+
|
| 27 |
+
```sh
|
| 28 |
+
$ sdk install java
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Run the application
|
| 32 |
+
|
| 33 |
+
1. Git clone the solverforge-solver-python repo and navigate to this directory:
|
| 34 |
+
|
| 35 |
+
```sh
|
| 36 |
+
$ git clone https://github.com/SolverForge/solverforge-quickstarts.git
|
| 37 |
+
...
|
| 38 |
+
$ cd solverforge-quickstarts/employee-scheduling-fast
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
2. Create a virtual environment:
|
| 42 |
+
|
| 43 |
+
```sh
|
| 44 |
+
$ python -m venv .venv
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
3. Activate the virtual environment:
|
| 48 |
+
|
| 49 |
+
```sh
|
| 50 |
+
$ . .venv/bin/activate
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
4. Install the application:
|
| 54 |
+
|
| 55 |
+
```sh
|
| 56 |
+
$ pip install -e .
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
5. Run the application:
|
| 60 |
+
|
| 61 |
+
```sh
|
| 62 |
+
$ run-app
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
6. Visit [http://localhost:8080](http://localhost:8080) in your browser.
|
| 66 |
+
|
| 67 |
+
7. Click on the **Solve** button.
|
| 68 |
+
|
| 69 |
+
## Test the application
|
| 70 |
+
|
| 71 |
+
1. Run tests:
|
| 72 |
+
|
| 73 |
+
```sh
|
| 74 |
+
$ pytest
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
## More information
|
| 78 |
+
|
| 79 |
+
Visit [solverforge.org](https://www.solverforge.org).
|
deploy/employee-scheduling/Chart.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: v2
|
| 2 |
+
name: employee-scheduling
|
| 3 |
+
description: A Helm chart for Employee Scheduling application
|
| 4 |
+
type: application
|
| 5 |
+
version: 1.0.1
|
| 6 |
+
appVersion: "1.0.1"
|
deploy/employee-scheduling/templates/_helpers.tpl
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{{/*
|
| 2 |
+
Expand the name of the chart.
|
| 3 |
+
*/}}
|
| 4 |
+
{{- define "employee-scheduling.name" -}}
|
| 5 |
+
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
| 6 |
+
{{- end }}
|
| 7 |
+
|
| 8 |
+
{{/*
|
| 9 |
+
Create a default fully qualified app name.
|
| 10 |
+
*/}}
|
| 11 |
+
{{- define "employee-scheduling.fullname" -}}
|
| 12 |
+
{{- if .Values.fullnameOverride }}
|
| 13 |
+
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
| 14 |
+
{{- else }}
|
| 15 |
+
{{- $name := default .Chart.Name .Values.nameOverride }}
|
| 16 |
+
{{- if contains $name .Release.Name }}
|
| 17 |
+
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
| 18 |
+
{{- else }}
|
| 19 |
+
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
| 20 |
+
{{- end }}
|
| 21 |
+
{{- end }}
|
| 22 |
+
{{- end }}
|
| 23 |
+
|
| 24 |
+
{{/*
|
| 25 |
+
Create chart name and version as used by the chart label.
|
| 26 |
+
*/}}
|
| 27 |
+
{{- define "employee-scheduling.chart" -}}
|
| 28 |
+
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
| 29 |
+
{{- end }}
|
| 30 |
+
|
| 31 |
+
{{/*
|
| 32 |
+
Common labels
|
| 33 |
+
*/}}
|
| 34 |
+
{{- define "employee-scheduling.labels" -}}
|
| 35 |
+
helm.sh/chart: {{ include "employee-scheduling.chart" . }}
|
| 36 |
+
{{ include "employee-scheduling.selectorLabels" . }}
|
| 37 |
+
{{- if .Chart.AppVersion }}
|
| 38 |
+
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
| 39 |
+
{{- end }}
|
| 40 |
+
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
| 41 |
+
{{- end }}
|
| 42 |
+
|
| 43 |
+
{{/*
|
| 44 |
+
Selector labels
|
| 45 |
+
*/}}
|
| 46 |
+
{{- define "employee-scheduling.selectorLabels" -}}
|
| 47 |
+
app.kubernetes.io/name: {{ include "employee-scheduling.name" . }}
|
| 48 |
+
app.kubernetes.io/instance: {{ .Release.Name }}
|
| 49 |
+
{{- end }}
|
deploy/employee-scheduling/templates/deployment.yaml
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: apps/v1
|
| 2 |
+
kind: Deployment
|
| 3 |
+
metadata:
|
| 4 |
+
name: {{ include "employee-scheduling.fullname" . }}
|
| 5 |
+
labels:
|
| 6 |
+
{{- include "employee-scheduling.labels" . | nindent 4 }}
|
| 7 |
+
spec:
|
| 8 |
+
replicas: {{ .Values.replicaCount }}
|
| 9 |
+
selector:
|
| 10 |
+
matchLabels:
|
| 11 |
+
{{- include "employee-scheduling.selectorLabels" . | nindent 6 }}
|
| 12 |
+
template:
|
| 13 |
+
metadata:
|
| 14 |
+
labels:
|
| 15 |
+
{{- include "employee-scheduling.selectorLabels" . | nindent 8 }}
|
| 16 |
+
{{- with .Values.podAnnotations }}
|
| 17 |
+
annotations:
|
| 18 |
+
{{- toYaml . | nindent 8 }}
|
| 19 |
+
{{- end }}
|
| 20 |
+
spec:
|
| 21 |
+
{{- if .Values.imagePullSecrets }}
|
| 22 |
+
imagePullSecrets:
|
| 23 |
+
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
|
| 24 |
+
{{- end }}
|
| 25 |
+
{{- with .Values.podSecurityContext }}
|
| 26 |
+
securityContext:
|
| 27 |
+
{{- toYaml . | nindent 8 }}
|
| 28 |
+
{{- end }}
|
| 29 |
+
containers:
|
| 30 |
+
- name: {{ include "employee-scheduling.name" . }}
|
| 31 |
+
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
| 32 |
+
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
| 33 |
+
ports:
|
| 34 |
+
- name: http
|
| 35 |
+
containerPort: {{ .Values.service.port }}
|
| 36 |
+
protocol: TCP
|
| 37 |
+
{{- with .Values.securityContext }}
|
| 38 |
+
securityContext:
|
| 39 |
+
{{- toYaml . | nindent 12 }}
|
| 40 |
+
{{- end }}
|
| 41 |
+
{{- if .Values.resources }}
|
| 42 |
+
resources:
|
| 43 |
+
{{- toYaml .Values.resources | nindent 12 }}
|
| 44 |
+
{{- end }}
|
| 45 |
+
livenessProbe:
|
| 46 |
+
httpGet:
|
| 47 |
+
path: /
|
| 48 |
+
port: http
|
| 49 |
+
initialDelaySeconds: 10
|
| 50 |
+
periodSeconds: 10
|
| 51 |
+
failureThreshold: 3
|
| 52 |
+
readinessProbe:
|
| 53 |
+
httpGet:
|
| 54 |
+
path: /
|
| 55 |
+
port: http
|
| 56 |
+
initialDelaySeconds: 5
|
| 57 |
+
periodSeconds: 5
|
| 58 |
+
failureThreshold: 3
|
| 59 |
+
{{- if .Values.nodeSelector }}
|
| 60 |
+
nodeSelector:
|
| 61 |
+
{{- toYaml .Values.nodeSelector | nindent 8 }}
|
| 62 |
+
{{- end }}
|
| 63 |
+
{{- if .Values.tolerations }}
|
| 64 |
+
tolerations:
|
| 65 |
+
{{- toYaml .Values.tolerations | nindent 8 }}
|
| 66 |
+
{{- end }}
|
| 67 |
+
{{- if .Values.affinity }}
|
| 68 |
+
affinity:
|
| 69 |
+
{{- toYaml .Values.affinity | nindent 8 }}
|
| 70 |
+
{{- end }}
|
deploy/employee-scheduling/templates/service.yaml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: v1
|
| 2 |
+
kind: Service
|
| 3 |
+
metadata:
|
| 4 |
+
name: {{ include "employee-scheduling.fullname" . }}
|
| 5 |
+
labels:
|
| 6 |
+
{{- include "employee-scheduling.labels" . | nindent 4 }}
|
| 7 |
+
spec:
|
| 8 |
+
type: {{ .Values.service.type }}
|
| 9 |
+
ports:
|
| 10 |
+
- port: {{ .Values.service.port }}
|
| 11 |
+
targetPort: http
|
| 12 |
+
protocol: TCP
|
| 13 |
+
name: http
|
| 14 |
+
{{- if .Values.service.nodePort }}
|
| 15 |
+
nodePort: {{ .Values.service.nodePort }}
|
| 16 |
+
{{- end }}
|
| 17 |
+
selector:
|
| 18 |
+
{{- include "employee-scheduling.selectorLabels" . | nindent 4 }}
|
deploy/employee-scheduling/values.yaml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
replicaCount: 1
|
| 2 |
+
|
| 3 |
+
image:
|
| 4 |
+
repository: employee-scheduling
|
| 5 |
+
pullPolicy: IfNotPresent
|
| 6 |
+
tag: "1.0.1"
|
| 7 |
+
|
| 8 |
+
imagePullSecrets: []
|
| 9 |
+
nameOverride: ""
|
| 10 |
+
fullnameOverride: ""
|
| 11 |
+
|
| 12 |
+
podAnnotations: {}
|
| 13 |
+
|
| 14 |
+
podSecurityContext: {}
|
| 15 |
+
|
| 16 |
+
securityContext: {}
|
| 17 |
+
|
| 18 |
+
service:
|
| 19 |
+
type: NodePort
|
| 20 |
+
port: 8080
|
| 21 |
+
nodePort: 30081
|
| 22 |
+
|
| 23 |
+
resources: {}
|
| 24 |
+
|
| 25 |
+
autoscaling:
|
| 26 |
+
enabled: false
|
| 27 |
+
minReplicas: 1
|
| 28 |
+
maxReplicas: 100
|
| 29 |
+
targetCPUUtilizationPercentage: 80
|
| 30 |
+
|
| 31 |
+
nodeSelector: {}
|
| 32 |
+
|
| 33 |
+
tolerations: []
|
| 34 |
+
|
| 35 |
+
affinity: {}
|
logging.conf
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[loggers]
|
| 2 |
+
keys=root,timefold_solver
|
| 3 |
+
|
| 4 |
+
[handlers]
|
| 5 |
+
keys=consoleHandler
|
| 6 |
+
|
| 7 |
+
[formatters]
|
| 8 |
+
keys=simpleFormatter
|
| 9 |
+
|
| 10 |
+
[logger_root]
|
| 11 |
+
level=INFO
|
| 12 |
+
handlers=consoleHandler
|
| 13 |
+
|
| 14 |
+
[logger_timefold_solver]
|
| 15 |
+
level=INFO
|
| 16 |
+
qualname=timefold.solver
|
| 17 |
+
handlers=consoleHandler
|
| 18 |
+
propagate=0
|
| 19 |
+
|
| 20 |
+
[handler_consoleHandler]
|
| 21 |
+
class=StreamHandler
|
| 22 |
+
level=INFO
|
| 23 |
+
formatter=simpleFormatter
|
| 24 |
+
args=(sys.stdout,)
|
| 25 |
+
|
| 26 |
+
[formatter_simpleFormatter]
|
| 27 |
+
class=uvicorn.logging.ColourizedFormatter
|
| 28 |
+
format={levelprefix:<8} @ {name} : {message}
|
| 29 |
+
style={
|
| 30 |
+
use_colors=True
|
pyproject.toml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["hatchling"]
|
| 3 |
+
build-backend = "hatchling.build"
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
[project]
|
| 7 |
+
name = "employee_scheduling"
|
| 8 |
+
version = "1.0.1"
|
| 9 |
+
requires-python = ">=3.10"
|
| 10 |
+
dependencies = [
|
| 11 |
+
'solverforge-legacy == 1.24.1',
|
| 12 |
+
'fastapi == 0.111.0',
|
| 13 |
+
'pydantic == 2.7.3',
|
| 14 |
+
'uvicorn == 0.30.1',
|
| 15 |
+
'pytest == 8.2.2',
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
[project.scripts]
|
| 20 |
+
run-app = "employee_scheduling:main"
|
src/employee_scheduling/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
|
| 3 |
+
from .rest_api import app
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def main():
|
| 7 |
+
config = uvicorn.Config(
|
| 8 |
+
"employee_scheduling:app",
|
| 9 |
+
host="0.0.0.0",
|
| 10 |
+
port=8080,
|
| 11 |
+
log_config="logging.conf",
|
| 12 |
+
use_colors=True,
|
| 13 |
+
)
|
| 14 |
+
server = uvicorn.Server(config)
|
| 15 |
+
server.run()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
if __name__ == "__main__":
|
| 19 |
+
main()
|
src/employee_scheduling/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (592 Bytes). View file
|
|
|
src/employee_scheduling/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (732 Bytes). View file
|
|
|
src/employee_scheduling/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (747 Bytes). View file
|
|
|
src/employee_scheduling/__pycache__/constraints.cpython-310.pyc
ADDED
|
Binary file (7.11 kB). View file
|
|
|
src/employee_scheduling/__pycache__/constraints.cpython-312.pyc
ADDED
|
Binary file (14.2 kB). View file
|
|
|
src/employee_scheduling/__pycache__/constraints.cpython-313.pyc
ADDED
|
Binary file (11.7 kB). View file
|
|
|
src/employee_scheduling/__pycache__/converters.cpython-310.pyc
ADDED
|
Binary file (3.61 kB). View file
|
|
|
src/employee_scheduling/__pycache__/converters.cpython-312.pyc
ADDED
|
Binary file (5.64 kB). View file
|
|
|
src/employee_scheduling/__pycache__/demo_data.cpython-310.pyc
ADDED
|
Binary file (6.3 kB). View file
|
|
|
src/employee_scheduling/__pycache__/demo_data.cpython-312.pyc
ADDED
|
Binary file (9.74 kB). View file
|
|
|
src/employee_scheduling/__pycache__/domain.cpython-310.pyc
ADDED
|
Binary file (3.02 kB). View file
|
|
|
src/employee_scheduling/__pycache__/domain.cpython-312.pyc
ADDED
|
Binary file (5.48 kB). View file
|
|
|
src/employee_scheduling/__pycache__/domain.cpython-313.pyc
ADDED
|
Binary file (4.24 kB). View file
|
|
|
src/employee_scheduling/__pycache__/json_serialization.cpython-310.pyc
ADDED
|
Binary file (1.22 kB). View file
|
|
|
src/employee_scheduling/__pycache__/json_serialization.cpython-312.pyc
ADDED
|
Binary file (1.47 kB). View file
|
|
|
src/employee_scheduling/__pycache__/rest_api.cpython-310.pyc
ADDED
|
Binary file (2.49 kB). View file
|
|
|
src/employee_scheduling/__pycache__/rest_api.cpython-312.pyc
ADDED
|
Binary file (3.37 kB). View file
|
|
|
src/employee_scheduling/__pycache__/solver.cpython-310.pyc
ADDED
|
Binary file (883 Bytes). View file
|
|
|
src/employee_scheduling/__pycache__/solver.cpython-312.pyc
ADDED
|
Binary file (1.08 kB). View file
|
|
|
src/employee_scheduling/constraints.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from solverforge_legacy.solver.score import (
|
| 2 |
+
constraint_provider,
|
| 3 |
+
ConstraintFactory,
|
| 4 |
+
Joiners,
|
| 5 |
+
HardSoftDecimalScore,
|
| 6 |
+
ConstraintCollectors,
|
| 7 |
+
)
|
| 8 |
+
from datetime import datetime, date
|
| 9 |
+
|
| 10 |
+
from .domain import Employee, Shift
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def get_minute_overlap(shift1: Shift, shift2: Shift) -> int:
|
| 14 |
+
return (
|
| 15 |
+
min(shift1.end, shift2.end) - max(shift1.start, shift2.start)
|
| 16 |
+
).total_seconds() // 60
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def is_overlapping_with_date(shift: Shift, dt: date) -> bool:
|
| 20 |
+
return shift.start.date() == dt or shift.end.date() == dt
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def overlapping_in_minutes(
|
| 24 |
+
first_start_datetime: datetime,
|
| 25 |
+
first_end_datetime: datetime,
|
| 26 |
+
second_start_datetime: datetime,
|
| 27 |
+
second_end_datetime: datetime,
|
| 28 |
+
) -> int:
|
| 29 |
+
latest_start = max(first_start_datetime, second_start_datetime)
|
| 30 |
+
earliest_end = min(first_end_datetime, second_end_datetime)
|
| 31 |
+
delta = (earliest_end - latest_start).total_seconds() / 60
|
| 32 |
+
return max(0, delta)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_shift_overlapping_duration_in_minutes(shift: Shift, dt: date) -> int:
|
| 36 |
+
overlap = 0
|
| 37 |
+
start_date_time = datetime.combine(dt, datetime.min.time())
|
| 38 |
+
end_date_time = datetime.combine(dt, datetime.max.time())
|
| 39 |
+
overlap += overlapping_in_minutes(
|
| 40 |
+
start_date_time, end_date_time, shift.start, shift.end
|
| 41 |
+
)
|
| 42 |
+
return overlap
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@constraint_provider
|
| 46 |
+
def define_constraints(constraint_factory: ConstraintFactory):
|
| 47 |
+
return [
|
| 48 |
+
# Hard constraints
|
| 49 |
+
required_skill(constraint_factory),
|
| 50 |
+
no_overlapping_shifts(constraint_factory),
|
| 51 |
+
at_least_10_hours_between_two_shifts(constraint_factory),
|
| 52 |
+
one_shift_per_day(constraint_factory),
|
| 53 |
+
unavailable_employee(constraint_factory),
|
| 54 |
+
max_shifts_per_employee(constraint_factory),
|
| 55 |
+
# Soft constraints
|
| 56 |
+
undesired_day_for_employee(constraint_factory),
|
| 57 |
+
desired_day_for_employee(constraint_factory),
|
| 58 |
+
balance_employee_shift_assignments(constraint_factory),
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def required_skill(constraint_factory: ConstraintFactory):
|
| 63 |
+
return (
|
| 64 |
+
constraint_factory.for_each(Shift)
|
| 65 |
+
.filter(lambda shift: not shift.has_required_skill())
|
| 66 |
+
.penalize(HardSoftDecimalScore.ONE_HARD)
|
| 67 |
+
.as_constraint("Missing required skill")
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def no_overlapping_shifts(constraint_factory: ConstraintFactory):
|
| 72 |
+
return (
|
| 73 |
+
constraint_factory.for_each_unique_pair(
|
| 74 |
+
Shift,
|
| 75 |
+
Joiners.equal(lambda shift: shift.employee.name),
|
| 76 |
+
Joiners.overlapping(lambda shift: shift.start, lambda shift: shift.end),
|
| 77 |
+
)
|
| 78 |
+
.penalize(HardSoftDecimalScore.ONE_HARD, get_minute_overlap)
|
| 79 |
+
.as_constraint("Overlapping shift")
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def at_least_10_hours_between_two_shifts(constraint_factory: ConstraintFactory):
|
| 84 |
+
return (
|
| 85 |
+
constraint_factory.for_each(Shift)
|
| 86 |
+
.join(
|
| 87 |
+
Shift,
|
| 88 |
+
Joiners.equal(lambda shift: shift.employee.name),
|
| 89 |
+
Joiners.less_than_or_equal(
|
| 90 |
+
lambda shift: shift.end, lambda shift: shift.start
|
| 91 |
+
),
|
| 92 |
+
)
|
| 93 |
+
.filter(
|
| 94 |
+
lambda first_shift, second_shift: (
|
| 95 |
+
second_shift.start - first_shift.end
|
| 96 |
+
).total_seconds()
|
| 97 |
+
// (60 * 60)
|
| 98 |
+
< 10
|
| 99 |
+
)
|
| 100 |
+
.penalize(
|
| 101 |
+
HardSoftDecimalScore.ONE_HARD,
|
| 102 |
+
lambda first_shift, second_shift: 600
|
| 103 |
+
- ((second_shift.start - first_shift.end).total_seconds() // 60),
|
| 104 |
+
)
|
| 105 |
+
.as_constraint("At least 10 hours between 2 shifts")
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def one_shift_per_day(constraint_factory: ConstraintFactory):
|
| 110 |
+
return (
|
| 111 |
+
constraint_factory.for_each_unique_pair(
|
| 112 |
+
Shift,
|
| 113 |
+
Joiners.equal(lambda shift: shift.employee.name),
|
| 114 |
+
Joiners.equal(lambda shift: shift.start.date()),
|
| 115 |
+
)
|
| 116 |
+
.penalize(HardSoftDecimalScore.ONE_HARD)
|
| 117 |
+
.as_constraint("Max one shift per day")
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def unavailable_employee(constraint_factory: ConstraintFactory):
|
| 122 |
+
return (
|
| 123 |
+
constraint_factory.for_each(Shift)
|
| 124 |
+
.join(
|
| 125 |
+
Employee,
|
| 126 |
+
Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
|
| 127 |
+
)
|
| 128 |
+
.flatten_last(lambda employee: employee.unavailable_dates)
|
| 129 |
+
.filter(lambda shift, unavailable_date: shift.is_overlapping_with_date(unavailable_date))
|
| 130 |
+
.penalize(
|
| 131 |
+
HardSoftDecimalScore.ONE_HARD,
|
| 132 |
+
lambda shift, unavailable_date: shift.get_overlapping_duration_in_minutes(unavailable_date),
|
| 133 |
+
)
|
| 134 |
+
.as_constraint("Unavailable employee")
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def max_shifts_per_employee(constraint_factory: ConstraintFactory):
|
| 139 |
+
"""
|
| 140 |
+
Hard constraint: No employee can have more than 12 shifts.
|
| 141 |
+
|
| 142 |
+
The limit of 12 is chosen based on the demo data dimensions:
|
| 143 |
+
- SMALL dataset: 139 shifts / 15 employees = ~9.3 average
|
| 144 |
+
- This provides headroom while preventing extreme imbalance
|
| 145 |
+
|
| 146 |
+
Note: A limit that's too low (e.g., 5) would make the problem infeasible.
|
| 147 |
+
Always ensure your constraints are compatible with your data dimensions.
|
| 148 |
+
"""
|
| 149 |
+
return (
|
| 150 |
+
constraint_factory.for_each(Shift)
|
| 151 |
+
.group_by(lambda shift: shift.employee, ConstraintCollectors.count())
|
| 152 |
+
.filter(lambda employee, shift_count: shift_count > 12)
|
| 153 |
+
.penalize(
|
| 154 |
+
HardSoftDecimalScore.ONE_HARD,
|
| 155 |
+
lambda employee, shift_count: shift_count - 12,
|
| 156 |
+
)
|
| 157 |
+
.as_constraint("Max 12 shifts per employee")
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def undesired_day_for_employee(constraint_factory: ConstraintFactory):
|
| 162 |
+
return (
|
| 163 |
+
constraint_factory.for_each(Shift)
|
| 164 |
+
.join(
|
| 165 |
+
Employee,
|
| 166 |
+
Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
|
| 167 |
+
)
|
| 168 |
+
.flatten_last(lambda employee: employee.undesired_dates)
|
| 169 |
+
.filter(lambda shift, undesired_date: shift.is_overlapping_with_date(undesired_date))
|
| 170 |
+
.penalize(
|
| 171 |
+
HardSoftDecimalScore.ONE_SOFT,
|
| 172 |
+
lambda shift, undesired_date: shift.get_overlapping_duration_in_minutes(undesired_date),
|
| 173 |
+
)
|
| 174 |
+
.as_constraint("Undesired day for employee")
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def desired_day_for_employee(constraint_factory: ConstraintFactory):
|
| 179 |
+
return (
|
| 180 |
+
constraint_factory.for_each(Shift)
|
| 181 |
+
.join(
|
| 182 |
+
Employee,
|
| 183 |
+
Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
|
| 184 |
+
)
|
| 185 |
+
.flatten_last(lambda employee: employee.desired_dates)
|
| 186 |
+
.filter(lambda shift, desired_date: shift.is_overlapping_with_date(desired_date))
|
| 187 |
+
.reward(
|
| 188 |
+
HardSoftDecimalScore.ONE_SOFT,
|
| 189 |
+
lambda shift, desired_date: shift.get_overlapping_duration_in_minutes(desired_date),
|
| 190 |
+
)
|
| 191 |
+
.as_constraint("Desired day for employee")
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def balance_employee_shift_assignments(constraint_factory: ConstraintFactory):
|
| 196 |
+
return (
|
| 197 |
+
constraint_factory.for_each(Shift)
|
| 198 |
+
.group_by(lambda shift: shift.employee, ConstraintCollectors.count())
|
| 199 |
+
.complement(
|
| 200 |
+
Employee, lambda e: 0
|
| 201 |
+
) # Include all employees which are not assigned to any shift.
|
| 202 |
+
.group_by(
|
| 203 |
+
ConstraintCollectors.load_balance(
|
| 204 |
+
lambda employee, shift_count: employee,
|
| 205 |
+
lambda employee, shift_count: shift_count,
|
| 206 |
+
)
|
| 207 |
+
)
|
| 208 |
+
.penalize_decimal(
|
| 209 |
+
HardSoftDecimalScore.ONE_SOFT,
|
| 210 |
+
lambda load_balance: load_balance.unfairness(),
|
| 211 |
+
)
|
| 212 |
+
.as_constraint("Balance employee shift assignments")
|
| 213 |
+
)
|
src/employee_scheduling/converters.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional, Union
|
| 2 |
+
from datetime import datetime, date
|
| 3 |
+
from . import domain
|
| 4 |
+
from .json_serialization import JsonDomainBase
|
| 5 |
+
from pydantic import Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# Conversion functions from domain to API models
|
| 9 |
+
def employee_to_model(employee: domain.Employee) -> domain.EmployeeModel:
|
| 10 |
+
return domain.EmployeeModel(
|
| 11 |
+
name=employee.name,
|
| 12 |
+
skills=list(employee.skills),
|
| 13 |
+
unavailable_dates=[d.isoformat() for d in employee.unavailable_dates],
|
| 14 |
+
undesired_dates=[d.isoformat() for d in employee.undesired_dates],
|
| 15 |
+
desired_dates=[d.isoformat() for d in employee.desired_dates],
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def shift_to_model(shift: domain.Shift) -> domain.ShiftModel:
|
| 20 |
+
return domain.ShiftModel(
|
| 21 |
+
id=shift.id,
|
| 22 |
+
start=shift.start.isoformat(),
|
| 23 |
+
end=shift.end.isoformat(),
|
| 24 |
+
location=shift.location,
|
| 25 |
+
required_skill=shift.required_skill,
|
| 26 |
+
employee=employee_to_model(shift.employee) if shift.employee else None,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def schedule_to_model(
|
| 31 |
+
schedule: domain.EmployeeSchedule,
|
| 32 |
+
) -> domain.EmployeeScheduleModel:
|
| 33 |
+
return domain.EmployeeScheduleModel(
|
| 34 |
+
employees=[employee_to_model(e) for e in schedule.employees],
|
| 35 |
+
shifts=[shift_to_model(s) for s in schedule.shifts],
|
| 36 |
+
score=str(schedule.score) if schedule.score else None,
|
| 37 |
+
solver_status=schedule.solver_status.name if schedule.solver_status else None,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# Conversion functions from API models to domain
|
| 42 |
+
def model_to_employee(model: domain.EmployeeModel) -> domain.Employee:
|
| 43 |
+
return domain.Employee(
|
| 44 |
+
name=model.name,
|
| 45 |
+
skills=set(model.skills),
|
| 46 |
+
unavailable_dates={date.fromisoformat(d) for d in model.unavailable_dates},
|
| 47 |
+
undesired_dates={date.fromisoformat(d) for d in model.undesired_dates},
|
| 48 |
+
desired_dates={date.fromisoformat(d) for d in model.desired_dates},
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def model_to_shift(model: domain.ShiftModel, employee_lookup: dict) -> domain.Shift:
|
| 53 |
+
# Handle employee reference
|
| 54 |
+
employee = None
|
| 55 |
+
if model.employee:
|
| 56 |
+
if isinstance(model.employee, str):
|
| 57 |
+
employee = employee_lookup[model.employee]
|
| 58 |
+
else:
|
| 59 |
+
employee = model_to_employee(model.employee)
|
| 60 |
+
|
| 61 |
+
return domain.Shift(
|
| 62 |
+
id=model.id,
|
| 63 |
+
start=datetime.fromisoformat(model.start),
|
| 64 |
+
end=datetime.fromisoformat(model.end),
|
| 65 |
+
location=model.location,
|
| 66 |
+
required_skill=model.required_skill,
|
| 67 |
+
employee=employee,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def model_to_schedule(model: domain.EmployeeScheduleModel) -> domain.EmployeeSchedule:
|
| 72 |
+
# Convert employees first
|
| 73 |
+
employees = [model_to_employee(e) for e in model.employees]
|
| 74 |
+
|
| 75 |
+
# Create lookup dictionary for employee references
|
| 76 |
+
employee_lookup = {e.name: e for e in employees}
|
| 77 |
+
|
| 78 |
+
# Convert shifts with employee lookups
|
| 79 |
+
shifts = [model_to_shift(s, employee_lookup) for s in model.shifts]
|
| 80 |
+
|
| 81 |
+
# Handle score
|
| 82 |
+
score = None
|
| 83 |
+
if model.score:
|
| 84 |
+
from solverforge_legacy.solver.score import HardSoftDecimalScore
|
| 85 |
+
|
| 86 |
+
score = HardSoftDecimalScore.parse(model.score)
|
| 87 |
+
|
| 88 |
+
# Handle solver status
|
| 89 |
+
solver_status = domain.SolverStatus.NOT_SOLVING
|
| 90 |
+
if model.solver_status:
|
| 91 |
+
solver_status = domain.SolverStatus[model.solver_status]
|
| 92 |
+
|
| 93 |
+
return domain.EmployeeSchedule(
|
| 94 |
+
employees=employees, shifts=shifts, score=score, solver_status=solver_status
|
| 95 |
+
)
|
src/employee_scheduling/demo_data.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date, datetime, time, timedelta
|
| 2 |
+
from itertools import product
|
| 3 |
+
from enum import Enum
|
| 4 |
+
from random import Random
|
| 5 |
+
from typing import Generator
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
|
| 8 |
+
from .domain import *
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class DemoData(Enum):
|
| 12 |
+
SMALL = 'SMALL'
|
| 13 |
+
LARGE = 'LARGE'
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True, kw_only=True)
|
| 17 |
+
class CountDistribution:
|
| 18 |
+
count: int
|
| 19 |
+
weight: float
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def counts(distributions: tuple[CountDistribution, ...]) -> tuple[int, ...]:
|
| 23 |
+
return tuple(distribution.count for distribution in distributions)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def weights(distributions: tuple[CountDistribution, ...]) -> tuple[float, ...]:
|
| 27 |
+
return tuple(distribution.weight for distribution in distributions)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(kw_only=True)
|
| 31 |
+
class DemoDataParameters:
|
| 32 |
+
locations: tuple[str, ...]
|
| 33 |
+
required_skills: tuple[str, ...]
|
| 34 |
+
optional_skills: tuple[str, ...]
|
| 35 |
+
days_in_schedule: int
|
| 36 |
+
employee_count: int
|
| 37 |
+
optional_skill_distribution: tuple[CountDistribution, ...]
|
| 38 |
+
shift_count_distribution: tuple[CountDistribution, ...]
|
| 39 |
+
availability_count_distribution: tuple[CountDistribution, ...]
|
| 40 |
+
random_seed: int = field(default=37)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
demo_data_to_parameters: dict[DemoData, DemoDataParameters] = {
|
| 44 |
+
DemoData.SMALL: DemoDataParameters(
|
| 45 |
+
locations=("Ambulatory care", "Critical care", "Pediatric care"),
|
| 46 |
+
required_skills=("Doctor", "Nurse"),
|
| 47 |
+
optional_skills=("Anaesthetics", "Cardiology"),
|
| 48 |
+
days_in_schedule=14,
|
| 49 |
+
employee_count=15,
|
| 50 |
+
optional_skill_distribution=(
|
| 51 |
+
CountDistribution(count=1, weight=3),
|
| 52 |
+
CountDistribution(count=2, weight=1)
|
| 53 |
+
),
|
| 54 |
+
shift_count_distribution=(
|
| 55 |
+
CountDistribution(count=1, weight=0.9),
|
| 56 |
+
CountDistribution(count=2, weight=0.1)
|
| 57 |
+
),
|
| 58 |
+
availability_count_distribution=(
|
| 59 |
+
CountDistribution(count=1, weight=4),
|
| 60 |
+
CountDistribution(count=2, weight=3),
|
| 61 |
+
CountDistribution(count=3, weight=2),
|
| 62 |
+
CountDistribution(count=4, weight=1)
|
| 63 |
+
),
|
| 64 |
+
random_seed=37
|
| 65 |
+
),
|
| 66 |
+
|
| 67 |
+
DemoData.LARGE: DemoDataParameters(
|
| 68 |
+
locations=("Ambulatory care",
|
| 69 |
+
"Neurology",
|
| 70 |
+
"Critical care",
|
| 71 |
+
"Pediatric care",
|
| 72 |
+
"Surgery",
|
| 73 |
+
"Radiology",
|
| 74 |
+
"Outpatient"),
|
| 75 |
+
required_skills=("Doctor", "Nurse"),
|
| 76 |
+
optional_skills=("Anaesthetics", "Cardiology", "Radiology"),
|
| 77 |
+
days_in_schedule=28,
|
| 78 |
+
employee_count=50,
|
| 79 |
+
optional_skill_distribution=(
|
| 80 |
+
CountDistribution(count=1, weight=3),
|
| 81 |
+
CountDistribution(count=2, weight=1)
|
| 82 |
+
),
|
| 83 |
+
shift_count_distribution=(
|
| 84 |
+
CountDistribution(count=1, weight=0.5),
|
| 85 |
+
CountDistribution(count=2, weight=0.3),
|
| 86 |
+
CountDistribution(count=3, weight=0.2)
|
| 87 |
+
),
|
| 88 |
+
availability_count_distribution=(
|
| 89 |
+
CountDistribution(count=5, weight=4),
|
| 90 |
+
CountDistribution(count=10, weight=3),
|
| 91 |
+
CountDistribution(count=15, weight=2),
|
| 92 |
+
CountDistribution(count=20, weight=1)
|
| 93 |
+
),
|
| 94 |
+
random_seed=37
|
| 95 |
+
)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
FIRST_NAMES = ("Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay")
|
| 100 |
+
LAST_NAMES = ("Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt")
|
| 101 |
+
SHIFT_LENGTH = timedelta(hours=8)
|
| 102 |
+
MORNING_SHIFT_START_TIME = time(hour=6, minute=0)
|
| 103 |
+
DAY_SHIFT_START_TIME = time(hour=9, minute=0)
|
| 104 |
+
AFTERNOON_SHIFT_START_TIME = time(hour=14, minute=0)
|
| 105 |
+
NIGHT_SHIFT_START_TIME = time(hour=22, minute=0)
|
| 106 |
+
|
| 107 |
+
SHIFT_START_TIMES_COMBOS = (
|
| 108 |
+
(MORNING_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME),
|
| 109 |
+
(MORNING_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME, NIGHT_SHIFT_START_TIME),
|
| 110 |
+
(MORNING_SHIFT_START_TIME, DAY_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME, NIGHT_SHIFT_START_TIME),
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
location_to_shift_start_time_list_map = dict()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def earliest_monday_on_or_after(target_date: date):
|
| 118 |
+
"""
|
| 119 |
+
Returns the date of the next given weekday after
|
| 120 |
+
the given date. For example, the date of next Monday.
|
| 121 |
+
|
| 122 |
+
NB: if it IS the day we're looking for, this returns 0.
|
| 123 |
+
consider then doing onDay(foo, day + 1).
|
| 124 |
+
"""
|
| 125 |
+
days = (7 - target_date.weekday()) % 7
|
| 126 |
+
return target_date + timedelta(days=days)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def generate_demo_data(demo_data_or_parameters: DemoData | DemoDataParameters) -> EmployeeSchedule:
|
| 130 |
+
global location_to_shift_start_time_list_map, demo_data_to_parameters
|
| 131 |
+
if isinstance(demo_data_or_parameters, DemoData):
|
| 132 |
+
parameters = demo_data_to_parameters[demo_data_or_parameters]
|
| 133 |
+
else:
|
| 134 |
+
parameters = demo_data_or_parameters
|
| 135 |
+
|
| 136 |
+
start_date = earliest_monday_on_or_after(date.today())
|
| 137 |
+
random = Random(parameters.random_seed)
|
| 138 |
+
shift_template_index = 0
|
| 139 |
+
for location in parameters.locations:
|
| 140 |
+
location_to_shift_start_time_list_map[location] = SHIFT_START_TIMES_COMBOS[shift_template_index]
|
| 141 |
+
shift_template_index = (shift_template_index + 1) % len(SHIFT_START_TIMES_COMBOS)
|
| 142 |
+
|
| 143 |
+
name_permutations = [f'{first_name} {last_name}'
|
| 144 |
+
for first_name, last_name in product(FIRST_NAMES, LAST_NAMES)]
|
| 145 |
+
random.shuffle(name_permutations)
|
| 146 |
+
|
| 147 |
+
employees = []
|
| 148 |
+
for i in range(parameters.employee_count):
|
| 149 |
+
count, = random.choices(population=counts(parameters.optional_skill_distribution),
|
| 150 |
+
weights=weights(parameters.optional_skill_distribution))
|
| 151 |
+
skills = []
|
| 152 |
+
skills += random.sample(parameters.optional_skills, count)
|
| 153 |
+
skills += random.sample(parameters.required_skills, 1)
|
| 154 |
+
employees.append(
|
| 155 |
+
Employee(name=name_permutations[i],
|
| 156 |
+
skills=set(skills))
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
shifts: list[Shift] = []
|
| 160 |
+
|
| 161 |
+
def id_generator():
|
| 162 |
+
current_id = 0
|
| 163 |
+
while True:
|
| 164 |
+
yield str(current_id)
|
| 165 |
+
current_id += 1
|
| 166 |
+
|
| 167 |
+
ids = id_generator()
|
| 168 |
+
|
| 169 |
+
for i in range(parameters.days_in_schedule):
|
| 170 |
+
count, = random.choices(population=counts(parameters.availability_count_distribution),
|
| 171 |
+
weights=weights(parameters.availability_count_distribution))
|
| 172 |
+
employees_with_availabilities_on_day = random.sample(employees, count)
|
| 173 |
+
current_date = start_date + timedelta(days=i)
|
| 174 |
+
for employee in employees_with_availabilities_on_day:
|
| 175 |
+
rand_num = random.randint(0, 2)
|
| 176 |
+
if rand_num == 0:
|
| 177 |
+
employee.unavailable_dates.add(current_date)
|
| 178 |
+
elif rand_num == 1:
|
| 179 |
+
employee.undesired_dates.add(current_date)
|
| 180 |
+
elif rand_num == 2:
|
| 181 |
+
employee.desired_dates.add(current_date)
|
| 182 |
+
shifts += generate_shifts_for_day(parameters, current_date, random, ids)
|
| 183 |
+
|
| 184 |
+
shift_count = 0
|
| 185 |
+
for shift in shifts:
|
| 186 |
+
shift.id = str(shift_count)
|
| 187 |
+
shift_count += 1
|
| 188 |
+
|
| 189 |
+
return EmployeeSchedule(
|
| 190 |
+
employees=employees,
|
| 191 |
+
shifts=shifts
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def generate_shifts_for_day(parameters: DemoDataParameters, current_date: date, random: Random,
|
| 196 |
+
ids: Generator[str, any, any]) -> list[Shift]:
|
| 197 |
+
global location_to_shift_start_time_list_map
|
| 198 |
+
shifts = []
|
| 199 |
+
for location in parameters.locations:
|
| 200 |
+
shift_start_times = location_to_shift_start_time_list_map[location]
|
| 201 |
+
for start_time in shift_start_times:
|
| 202 |
+
shift_start_date_time = datetime.combine(current_date, start_time)
|
| 203 |
+
shift_end_date_time = shift_start_date_time + SHIFT_LENGTH
|
| 204 |
+
shifts += generate_shifts_for_timeslot(parameters, shift_start_date_time, shift_end_date_time,
|
| 205 |
+
location, random, ids)
|
| 206 |
+
|
| 207 |
+
return shifts
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def generate_shifts_for_timeslot(parameters: DemoDataParameters, timeslot_start: datetime, timeslot_end: datetime,
|
| 211 |
+
location: str, random: Random, ids: Generator[str, any, any]) -> list[Shift]:
|
| 212 |
+
shift_count, = random.choices(population=counts(parameters.shift_count_distribution),
|
| 213 |
+
weights=weights(parameters.shift_count_distribution))
|
| 214 |
+
|
| 215 |
+
shifts = []
|
| 216 |
+
for i in range(shift_count):
|
| 217 |
+
if random.random() >= 0.5:
|
| 218 |
+
required_skill = random.choice(parameters.required_skills)
|
| 219 |
+
else:
|
| 220 |
+
required_skill = random.choice(parameters.optional_skills)
|
| 221 |
+
shifts.append(Shift(
|
| 222 |
+
id=next(ids),
|
| 223 |
+
start=timeslot_start,
|
| 224 |
+
end=timeslot_end,
|
| 225 |
+
location=location,
|
| 226 |
+
required_skill=required_skill))
|
| 227 |
+
|
| 228 |
+
return shifts
|
src/employee_scheduling/domain.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from solverforge_legacy.solver import SolverStatus
|
| 2 |
+
from solverforge_legacy.solver.domain import (
|
| 3 |
+
planning_entity,
|
| 4 |
+
planning_solution,
|
| 5 |
+
PlanningId,
|
| 6 |
+
PlanningVariable,
|
| 7 |
+
PlanningEntityCollectionProperty,
|
| 8 |
+
ProblemFactCollectionProperty,
|
| 9 |
+
ValueRangeProvider,
|
| 10 |
+
PlanningScore,
|
| 11 |
+
)
|
| 12 |
+
from solverforge_legacy.solver.score import HardSoftDecimalScore
|
| 13 |
+
from datetime import datetime, date
|
| 14 |
+
from typing import Annotated, List, Optional, Union
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from .json_serialization import JsonDomainBase
|
| 17 |
+
from pydantic import Field
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class Employee:
|
| 22 |
+
name: Annotated[str, PlanningId]
|
| 23 |
+
skills: set[str] = field(default_factory=set)
|
| 24 |
+
unavailable_dates: set[date] = field(default_factory=set)
|
| 25 |
+
undesired_dates: set[date] = field(default_factory=set)
|
| 26 |
+
desired_dates: set[date] = field(default_factory=set)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@planning_entity
|
| 30 |
+
@dataclass
|
| 31 |
+
class Shift:
|
| 32 |
+
id: Annotated[str, PlanningId]
|
| 33 |
+
start: datetime
|
| 34 |
+
end: datetime
|
| 35 |
+
location: str
|
| 36 |
+
required_skill: str
|
| 37 |
+
employee: Annotated[Employee | None, PlanningVariable] = None
|
| 38 |
+
|
| 39 |
+
def has_required_skill(self) -> bool:
|
| 40 |
+
"""Check if assigned employee has the required skill."""
|
| 41 |
+
if self.employee is None:
|
| 42 |
+
return False
|
| 43 |
+
return self.required_skill in self.employee.skills
|
| 44 |
+
|
| 45 |
+
def is_overlapping_with_date(self, dt: date) -> bool:
|
| 46 |
+
"""Check if shift overlaps with a specific date."""
|
| 47 |
+
return self.start.date() == dt or self.end.date() == dt
|
| 48 |
+
|
| 49 |
+
def get_overlapping_duration_in_minutes(self, dt: date) -> int:
|
| 50 |
+
"""Calculate overlap duration in minutes for a specific date."""
|
| 51 |
+
start_date_time = datetime.combine(dt, datetime.min.time())
|
| 52 |
+
end_date_time = datetime.combine(dt, datetime.max.time())
|
| 53 |
+
|
| 54 |
+
# Calculate overlap between date range and shift range
|
| 55 |
+
max_start_time = max(start_date_time, self.start)
|
| 56 |
+
min_end_time = min(end_date_time, self.end)
|
| 57 |
+
|
| 58 |
+
minutes = (min_end_time - max_start_time).total_seconds() / 60
|
| 59 |
+
return int(max(0, minutes))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@planning_solution
|
| 63 |
+
@dataclass
|
| 64 |
+
class EmployeeSchedule:
|
| 65 |
+
employees: Annotated[
|
| 66 |
+
list[Employee], ProblemFactCollectionProperty, ValueRangeProvider
|
| 67 |
+
]
|
| 68 |
+
shifts: Annotated[list[Shift], PlanningEntityCollectionProperty]
|
| 69 |
+
score: Annotated[HardSoftDecimalScore | None, PlanningScore] = None
|
| 70 |
+
solver_status: SolverStatus = SolverStatus.NOT_SOLVING
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# Pydantic REST models for API (used for deserialization and context)
|
| 74 |
+
class EmployeeModel(JsonDomainBase):
|
| 75 |
+
name: str
|
| 76 |
+
skills: List[str] = Field(default_factory=list)
|
| 77 |
+
unavailable_dates: List[str] = Field(default_factory=list, alias="unavailableDates")
|
| 78 |
+
undesired_dates: List[str] = Field(default_factory=list, alias="undesiredDates")
|
| 79 |
+
desired_dates: List[str] = Field(default_factory=list, alias="desiredDates")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class ShiftModel(JsonDomainBase):
|
| 83 |
+
id: str
|
| 84 |
+
start: str # ISO datetime string
|
| 85 |
+
end: str # ISO datetime string
|
| 86 |
+
location: str
|
| 87 |
+
required_skill: str = Field(..., alias="requiredSkill")
|
| 88 |
+
employee: Union[str, EmployeeModel, None] = None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class EmployeeScheduleModel(JsonDomainBase):
|
| 92 |
+
employees: List[EmployeeModel]
|
| 93 |
+
shifts: List[ShiftModel]
|
| 94 |
+
score: Optional[str] = None
|
| 95 |
+
solver_status: Optional[str] = None
|
src/employee_scheduling/json_serialization.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from solverforge_legacy.solver.score import HardSoftDecimalScore
|
| 2 |
+
from typing import Any
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, PlainSerializer, BeforeValidator
|
| 4 |
+
from pydantic.alias_generators import to_camel
|
| 5 |
+
|
| 6 |
+
ScoreSerializer = PlainSerializer(
|
| 7 |
+
lambda score: str(score) if score is not None else None, return_type=str | None
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def validate_score(v: Any) -> Any:
|
| 12 |
+
if isinstance(v, HardSoftDecimalScore) or v is None:
|
| 13 |
+
return v
|
| 14 |
+
if isinstance(v, str):
|
| 15 |
+
return HardSoftDecimalScore.parse(v)
|
| 16 |
+
raise ValueError('"score" should be a string')
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
ScoreValidator = BeforeValidator(validate_score)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class JsonDomainBase(BaseModel):
|
| 23 |
+
model_config = ConfigDict(
|
| 24 |
+
alias_generator=to_camel,
|
| 25 |
+
populate_by_name=True,
|
| 26 |
+
from_attributes=True,
|
| 27 |
+
)
|
src/employee_scheduling/rest_api.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.staticfiles import StaticFiles
|
| 3 |
+
from uuid import uuid4
|
| 4 |
+
from dataclasses import replace
|
| 5 |
+
|
| 6 |
+
from .domain import EmployeeSchedule, EmployeeScheduleModel
|
| 7 |
+
from .converters import (
|
| 8 |
+
schedule_to_model, model_to_schedule
|
| 9 |
+
)
|
| 10 |
+
from .demo_data import DemoData, generate_demo_data
|
| 11 |
+
from .solver import solver_manager, solution_manager
|
| 12 |
+
|
| 13 |
+
app = FastAPI(docs_url='/q/swagger-ui')
|
| 14 |
+
data_sets: dict[str, EmployeeSchedule] = {}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@app.get("/demo-data")
|
| 18 |
+
async def demo_data_list() -> list[DemoData]:
|
| 19 |
+
return [e for e in DemoData]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@app.get("/demo-data/{dataset_id}", response_model_exclude_none=True)
|
| 23 |
+
async def get_demo_data(dataset_id: str) -> EmployeeScheduleModel:
|
| 24 |
+
demo_data = getattr(DemoData, dataset_id)
|
| 25 |
+
domain_schedule = generate_demo_data(demo_data)
|
| 26 |
+
return schedule_to_model(domain_schedule)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@app.get("/schedules/{problem_id}", response_model_exclude_none=True)
|
| 30 |
+
async def get_timetable(problem_id: str) -> EmployeeScheduleModel:
|
| 31 |
+
schedule = data_sets[problem_id]
|
| 32 |
+
updated_schedule = replace(schedule, solver_status=solver_manager.get_solver_status(problem_id))
|
| 33 |
+
return schedule_to_model(updated_schedule)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def update_schedule(problem_id: str, schedule: EmployeeSchedule):
|
| 37 |
+
global data_sets
|
| 38 |
+
data_sets[problem_id] = schedule
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@app.post("/schedules")
|
| 42 |
+
async def solve_timetable(schedule_model: EmployeeScheduleModel) -> str:
|
| 43 |
+
job_id = str(uuid4())
|
| 44 |
+
schedule = model_to_schedule(schedule_model)
|
| 45 |
+
data_sets[job_id] = schedule
|
| 46 |
+
solver_manager.solve_and_listen(job_id, schedule,
|
| 47 |
+
lambda solution: update_schedule(job_id, solution))
|
| 48 |
+
return job_id
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@app.delete("/schedules/{problem_id}")
|
| 52 |
+
async def stop_solving(problem_id: str) -> None:
|
| 53 |
+
solver_manager.terminate_early(problem_id)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
src/employee_scheduling/solver.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from solverforge_legacy.solver import SolverManager, SolverFactory, SolutionManager
|
| 2 |
+
from solverforge_legacy.solver.config import (
|
| 3 |
+
SolverConfig,
|
| 4 |
+
ScoreDirectorFactoryConfig,
|
| 5 |
+
TerminationConfig,
|
| 6 |
+
Duration,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
from .domain import EmployeeSchedule, Shift
|
| 10 |
+
from .constraints import define_constraints
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
solver_config = SolverConfig(
|
| 14 |
+
solution_class=EmployeeSchedule,
|
| 15 |
+
entity_class_list=[Shift],
|
| 16 |
+
score_director_factory_config=ScoreDirectorFactoryConfig(
|
| 17 |
+
constraint_provider_function=define_constraints
|
| 18 |
+
),
|
| 19 |
+
termination_config=TerminationConfig(spent_limit=Duration(seconds=30)),
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
solver_manager = SolverManager.create(SolverFactory.create(solver_config))
|
| 23 |
+
solution_manager = SolutionManager.create(solver_manager)
|
static/app.js
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let autoRefreshIntervalId = null;
|
| 2 |
+
const zoomMin = 2 * 1000 * 60 * 60 * 24 // 2 day in milliseconds
|
| 3 |
+
const zoomMax = 4 * 7 * 1000 * 60 * 60 * 24 // 4 weeks in milliseconds
|
| 4 |
+
|
| 5 |
+
const UNAVAILABLE_COLOR = '#ef2929' // Tango Scarlet Red
|
| 6 |
+
const UNDESIRED_COLOR = '#f57900' // Tango Orange
|
| 7 |
+
const DESIRED_COLOR = '#73d216' // Tango Chameleon
|
| 8 |
+
|
| 9 |
+
let demoDataId = null;
|
| 10 |
+
let scheduleId = null;
|
| 11 |
+
let loadedSchedule = null;
|
| 12 |
+
|
| 13 |
+
const byEmployeePanel = document.getElementById("byEmployeePanel");
|
| 14 |
+
const byEmployeeTimelineOptions = {
|
| 15 |
+
timeAxis: {scale: "hour", step: 6},
|
| 16 |
+
orientation: {axis: "top"},
|
| 17 |
+
stack: false,
|
| 18 |
+
xss: {disabled: true}, // Items are XSS safe through JQuery
|
| 19 |
+
zoomMin: zoomMin,
|
| 20 |
+
zoomMax: zoomMax,
|
| 21 |
+
};
|
| 22 |
+
let byEmployeeGroupDataSet = new vis.DataSet();
|
| 23 |
+
let byEmployeeItemDataSet = new vis.DataSet();
|
| 24 |
+
let byEmployeeTimeline = new vis.Timeline(byEmployeePanel, byEmployeeItemDataSet, byEmployeeGroupDataSet, byEmployeeTimelineOptions);
|
| 25 |
+
|
| 26 |
+
const byLocationPanel = document.getElementById("byLocationPanel");
|
| 27 |
+
const byLocationTimelineOptions = {
|
| 28 |
+
timeAxis: {scale: "hour", step: 6},
|
| 29 |
+
orientation: {axis: "top"},
|
| 30 |
+
xss: {disabled: true}, // Items are XSS safe through JQuery
|
| 31 |
+
zoomMin: zoomMin,
|
| 32 |
+
zoomMax: zoomMax,
|
| 33 |
+
};
|
| 34 |
+
let byLocationGroupDataSet = new vis.DataSet();
|
| 35 |
+
let byLocationItemDataSet = new vis.DataSet();
|
| 36 |
+
let byLocationTimeline = new vis.Timeline(byLocationPanel, byLocationItemDataSet, byLocationGroupDataSet, byLocationTimelineOptions);
|
| 37 |
+
|
| 38 |
+
let windowStart = JSJoda.LocalDate.now().toString();
|
| 39 |
+
let windowEnd = JSJoda.LocalDate.parse(windowStart).plusDays(7).toString();
|
| 40 |
+
|
| 41 |
+
$(document).ready(function () {
|
| 42 |
+
let initialized = false;
|
| 43 |
+
|
| 44 |
+
function safeInitialize() {
|
| 45 |
+
if (!initialized) {
|
| 46 |
+
initialized = true;
|
| 47 |
+
initializeApp();
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// Ensure all resources are loaded before initializing
|
| 52 |
+
$(window).on('load', safeInitialize);
|
| 53 |
+
|
| 54 |
+
// Fallback if window load event doesn't fire
|
| 55 |
+
setTimeout(safeInitialize, 100);
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
function initializeApp() {
|
| 59 |
+
replaceQuickstartSolverForgeAutoHeaderFooter();
|
| 60 |
+
|
| 61 |
+
$("#solveButton").click(function () {
|
| 62 |
+
solve();
|
| 63 |
+
});
|
| 64 |
+
$("#stopSolvingButton").click(function () {
|
| 65 |
+
stopSolving();
|
| 66 |
+
});
|
| 67 |
+
$("#analyzeButton").click(function () {
|
| 68 |
+
analyze();
|
| 69 |
+
});
|
| 70 |
+
// HACK to allow vis-timeline to work within Bootstrap tabs
|
| 71 |
+
$("#byEmployeeTab").on('shown.bs.tab', function (event) {
|
| 72 |
+
byEmployeeTimeline.redraw();
|
| 73 |
+
})
|
| 74 |
+
$("#byLocationTab").on('shown.bs.tab', function (event) {
|
| 75 |
+
byLocationTimeline.redraw();
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
setupAjax();
|
| 79 |
+
fetchDemoData();
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function setupAjax() {
|
| 83 |
+
$.ajaxSetup({
|
| 84 |
+
headers: {
|
| 85 |
+
'Content-Type': 'application/json',
|
| 86 |
+
'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job
|
| 87 |
+
}
|
| 88 |
+
});
|
| 89 |
+
// Extend jQuery to support $.put() and $.delete()
|
| 90 |
+
jQuery.each(["put", "delete"], function (i, method) {
|
| 91 |
+
jQuery[method] = function (url, data, callback, type) {
|
| 92 |
+
if (jQuery.isFunction(data)) {
|
| 93 |
+
type = type || callback;
|
| 94 |
+
callback = data;
|
| 95 |
+
data = undefined;
|
| 96 |
+
}
|
| 97 |
+
return jQuery.ajax({
|
| 98 |
+
url: url,
|
| 99 |
+
type: method,
|
| 100 |
+
dataType: type,
|
| 101 |
+
data: data,
|
| 102 |
+
success: callback
|
| 103 |
+
});
|
| 104 |
+
};
|
| 105 |
+
});
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function fetchDemoData() {
|
| 109 |
+
$.get("/demo-data", function (data) {
|
| 110 |
+
data.forEach(item => {
|
| 111 |
+
$("#testDataButton").append($('<a id="' + item + 'TestData" class="dropdown-item" href="#">' + item + '</a>'));
|
| 112 |
+
$("#" + item + "TestData").click(function () {
|
| 113 |
+
switchDataDropDownItemActive(item);
|
| 114 |
+
scheduleId = null;
|
| 115 |
+
demoDataId = item;
|
| 116 |
+
|
| 117 |
+
refreshSchedule();
|
| 118 |
+
});
|
| 119 |
+
});
|
| 120 |
+
demoDataId = data[0];
|
| 121 |
+
switchDataDropDownItemActive(demoDataId);
|
| 122 |
+
refreshSchedule();
|
| 123 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 124 |
+
// disable this page as there is no data
|
| 125 |
+
let $demo = $("#demo");
|
| 126 |
+
$demo.empty();
|
| 127 |
+
$demo.html("<h1><p align=\"center\">No test data available</p></h1>")
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function switchDataDropDownItemActive(newItem) {
|
| 132 |
+
activeCssClass = "active";
|
| 133 |
+
$("#testDataButton > a." + activeCssClass).removeClass(activeCssClass);
|
| 134 |
+
$("#" + newItem + "TestData").addClass(activeCssClass);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function getShiftColor(shift, employee) {
|
| 138 |
+
const shiftStart = JSJoda.LocalDateTime.parse(shift.start);
|
| 139 |
+
const shiftStartDateString = shiftStart.toLocalDate().toString();
|
| 140 |
+
const shiftEnd = JSJoda.LocalDateTime.parse(shift.end);
|
| 141 |
+
const shiftEndDateString = shiftEnd.toLocalDate().toString();
|
| 142 |
+
if (employee.unavailableDates.includes(shiftStartDateString) ||
|
| 143 |
+
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
| 144 |
+
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
| 145 |
+
employee.unavailableDates.includes(shiftEndDateString))) {
|
| 146 |
+
return UNAVAILABLE_COLOR
|
| 147 |
+
} else if (employee.undesiredDates.includes(shiftStartDateString) ||
|
| 148 |
+
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
| 149 |
+
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
| 150 |
+
employee.undesiredDates.includes(shiftEndDateString))) {
|
| 151 |
+
return UNDESIRED_COLOR
|
| 152 |
+
} else if (employee.desiredDates.includes(shiftStartDateString) ||
|
| 153 |
+
// The contains() check is ignored for a shift end at midnight (00:00:00).
|
| 154 |
+
(shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
|
| 155 |
+
employee.desiredDates.includes(shiftEndDateString))) {
|
| 156 |
+
return DESIRED_COLOR
|
| 157 |
+
} else {
|
| 158 |
+
return " #729fcf"; // Tango Sky Blue
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
function refreshSchedule() {
|
| 163 |
+
let path = "/schedules/" + scheduleId;
|
| 164 |
+
if (scheduleId === null) {
|
| 165 |
+
if (demoDataId === null) {
|
| 166 |
+
alert("Please select a test data set.");
|
| 167 |
+
return;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
path = "/demo-data/" + demoDataId;
|
| 171 |
+
}
|
| 172 |
+
$.getJSON(path, function (schedule) {
|
| 173 |
+
loadedSchedule = schedule;
|
| 174 |
+
renderSchedule(schedule);
|
| 175 |
+
})
|
| 176 |
+
.fail(function (xhr, ajaxOptions, thrownError) {
|
| 177 |
+
showError("Getting the schedule has failed.", xhr);
|
| 178 |
+
refreshSolvingButtons(false);
|
| 179 |
+
});
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function renderSchedule(schedule) {
|
| 183 |
+
console.log('Rendering schedule:', schedule);
|
| 184 |
+
|
| 185 |
+
if (!schedule) {
|
| 186 |
+
console.error('No schedule data provided to renderSchedule');
|
| 187 |
+
return;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING");
|
| 191 |
+
$("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score));
|
| 192 |
+
|
| 193 |
+
const unassignedShifts = $("#unassignedShifts");
|
| 194 |
+
const groups = [];
|
| 195 |
+
|
| 196 |
+
// Check if schedule.shifts exists and is an array
|
| 197 |
+
if (!schedule.shifts || !Array.isArray(schedule.shifts) || schedule.shifts.length === 0) {
|
| 198 |
+
console.warn('No shifts data available in schedule');
|
| 199 |
+
return;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// Show only first 7 days of draft
|
| 203 |
+
const scheduleStart = schedule.shifts.map(shift => JSJoda.LocalDateTime.parse(shift.start).toLocalDate()).sort()[0].toString();
|
| 204 |
+
const scheduleEnd = JSJoda.LocalDate.parse(scheduleStart).plusDays(7).toString();
|
| 205 |
+
|
| 206 |
+
windowStart = scheduleStart;
|
| 207 |
+
windowEnd = scheduleEnd;
|
| 208 |
+
|
| 209 |
+
unassignedShifts.children().remove();
|
| 210 |
+
let unassignedShiftsCount = 0;
|
| 211 |
+
byEmployeeGroupDataSet.clear();
|
| 212 |
+
byLocationGroupDataSet.clear();
|
| 213 |
+
|
| 214 |
+
byEmployeeItemDataSet.clear();
|
| 215 |
+
byLocationItemDataSet.clear();
|
| 216 |
+
|
| 217 |
+
// Check if schedule.employees exists and is an array
|
| 218 |
+
if (!schedule.employees || !Array.isArray(schedule.employees)) {
|
| 219 |
+
console.warn('No employees data available in schedule');
|
| 220 |
+
return;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
schedule.employees.forEach((employee, index) => {
|
| 224 |
+
const employeeGroupElement = $('<div class="card-body p-2"/>')
|
| 225 |
+
.append($(`<h5 class="card-title mb-2"/>)`)
|
| 226 |
+
.append(employee.name))
|
| 227 |
+
.append($('<div/>')
|
| 228 |
+
.append($(employee.skills.map(skill => `<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${skill}</span>`).join(''))));
|
| 229 |
+
byEmployeeGroupDataSet.add({id: employee.name, content: employeeGroupElement.html()});
|
| 230 |
+
|
| 231 |
+
employee.unavailableDates.forEach((rawDate, dateIndex) => {
|
| 232 |
+
const date = JSJoda.LocalDate.parse(rawDate)
|
| 233 |
+
const start = date.atStartOfDay().toString();
|
| 234 |
+
const end = date.plusDays(1).atStartOfDay().toString();
|
| 235 |
+
const byEmployeeShiftElement = $(`<div/>`)
|
| 236 |
+
.append($(`<h5 class="card-title mb-1"/>`).text("Unavailable"));
|
| 237 |
+
byEmployeeItemDataSet.add({
|
| 238 |
+
id: "employee-" + index + "-unavailability-" + dateIndex, group: employee.name,
|
| 239 |
+
content: byEmployeeShiftElement.html(),
|
| 240 |
+
start: start, end: end,
|
| 241 |
+
type: "background",
|
| 242 |
+
style: "opacity: 0.5; background-color: " + UNAVAILABLE_COLOR,
|
| 243 |
+
});
|
| 244 |
+
});
|
| 245 |
+
employee.undesiredDates.forEach((rawDate, dateIndex) => {
|
| 246 |
+
const date = JSJoda.LocalDate.parse(rawDate)
|
| 247 |
+
const start = date.atStartOfDay().toString();
|
| 248 |
+
const end = date.plusDays(1).atStartOfDay().toString();
|
| 249 |
+
const byEmployeeShiftElement = $(`<div/>`)
|
| 250 |
+
.append($(`<h5 class="card-title mb-1"/>`).text("Undesired"));
|
| 251 |
+
byEmployeeItemDataSet.add({
|
| 252 |
+
id: "employee-" + index + "-undesired-" + dateIndex, group: employee.name,
|
| 253 |
+
content: byEmployeeShiftElement.html(),
|
| 254 |
+
start: start, end: end,
|
| 255 |
+
type: "background",
|
| 256 |
+
style: "opacity: 0.5; background-color: " + UNDESIRED_COLOR,
|
| 257 |
+
});
|
| 258 |
+
});
|
| 259 |
+
employee.desiredDates.forEach((rawDate, dateIndex) => {
|
| 260 |
+
const date = JSJoda.LocalDate.parse(rawDate)
|
| 261 |
+
const start = date.atStartOfDay().toString();
|
| 262 |
+
const end = date.plusDays(1).atStartOfDay().toString();
|
| 263 |
+
const byEmployeeShiftElement = $(`<div/>`)
|
| 264 |
+
.append($(`<h5 class="card-title mb-1"/>`).text("Desired"));
|
| 265 |
+
byEmployeeItemDataSet.add({
|
| 266 |
+
id: "employee-" + index + "-desired-" + dateIndex, group: employee.name,
|
| 267 |
+
content: byEmployeeShiftElement.html(),
|
| 268 |
+
start: start, end: end,
|
| 269 |
+
type: "background",
|
| 270 |
+
style: "opacity: 0.5; background-color: " + DESIRED_COLOR,
|
| 271 |
+
});
|
| 272 |
+
});
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
schedule.shifts.forEach((shift, index) => {
|
| 276 |
+
if (groups.indexOf(shift.location) === -1) {
|
| 277 |
+
groups.push(shift.location);
|
| 278 |
+
byLocationGroupDataSet.add({
|
| 279 |
+
id: shift.location,
|
| 280 |
+
content: shift.location,
|
| 281 |
+
});
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
if (shift.employee == null) {
|
| 285 |
+
unassignedShiftsCount++;
|
| 286 |
+
|
| 287 |
+
const byLocationShiftElement = $('<div class="card-body p-2"/>')
|
| 288 |
+
.append($(`<h5 class="card-title mb-2"/>)`)
|
| 289 |
+
.append("Unassigned"))
|
| 290 |
+
.append($('<div/>')
|
| 291 |
+
.append($(`<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${shift.requiredSkill}</span>`)));
|
| 292 |
+
|
| 293 |
+
byLocationItemDataSet.add({
|
| 294 |
+
id: 'shift-' + index, group: shift.location,
|
| 295 |
+
content: byLocationShiftElement.html(),
|
| 296 |
+
start: shift.start, end: shift.end,
|
| 297 |
+
style: "background-color: #EF292999"
|
| 298 |
+
});
|
| 299 |
+
} else {
|
| 300 |
+
const skillColor = (shift.employee.skills.indexOf(shift.requiredSkill) === -1 ? '#ef2929' : '#8ae234');
|
| 301 |
+
const byEmployeeShiftElement = $('<div class="card-body p-2"/>')
|
| 302 |
+
.append($(`<h5 class="card-title mb-2"/>)`)
|
| 303 |
+
.append(shift.location))
|
| 304 |
+
.append($('<div/>')
|
| 305 |
+
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
|
| 306 |
+
const byLocationShiftElement = $('<div class="card-body p-2"/>')
|
| 307 |
+
.append($(`<h5 class="card-title mb-2"/>)`)
|
| 308 |
+
.append(shift.employee.name))
|
| 309 |
+
.append($('<div/>')
|
| 310 |
+
.append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
|
| 311 |
+
|
| 312 |
+
const shiftColor = getShiftColor(shift, shift.employee);
|
| 313 |
+
byEmployeeItemDataSet.add({
|
| 314 |
+
id: 'shift-' + index, group: shift.employee.name,
|
| 315 |
+
content: byEmployeeShiftElement.html(),
|
| 316 |
+
start: shift.start, end: shift.end,
|
| 317 |
+
style: "background-color: " + shiftColor
|
| 318 |
+
});
|
| 319 |
+
byLocationItemDataSet.add({
|
| 320 |
+
id: 'shift-' + index, group: shift.location,
|
| 321 |
+
content: byLocationShiftElement.html(),
|
| 322 |
+
start: shift.start, end: shift.end,
|
| 323 |
+
style: "background-color: " + shiftColor
|
| 324 |
+
});
|
| 325 |
+
}
|
| 326 |
+
});
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
if (unassignedShiftsCount === 0) {
|
| 330 |
+
unassignedShifts.append($(`<p/>`).text(`There are no unassigned shifts.`));
|
| 331 |
+
} else {
|
| 332 |
+
unassignedShifts.append($(`<p/>`).text(`There are ${unassignedShiftsCount} unassigned shifts.`));
|
| 333 |
+
}
|
| 334 |
+
byEmployeeTimeline.setWindow(scheduleStart, scheduleEnd);
|
| 335 |
+
byLocationTimeline.setWindow(scheduleStart, scheduleEnd);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
function solve() {
|
| 339 |
+
if (!loadedSchedule) {
|
| 340 |
+
showError("No schedule data loaded. Please wait for the data to load or refresh the page.");
|
| 341 |
+
return;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
console.log('Sending schedule data for solving:', loadedSchedule);
|
| 345 |
+
$.post("/schedules", JSON.stringify(loadedSchedule), function (data) {
|
| 346 |
+
scheduleId = data;
|
| 347 |
+
refreshSolvingButtons(true);
|
| 348 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 349 |
+
showError("Start solving failed.", xhr);
|
| 350 |
+
refreshSolvingButtons(false);
|
| 351 |
+
},
|
| 352 |
+
"text");
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
function analyze() {
|
| 356 |
+
new bootstrap.Modal("#scoreAnalysisModal").show()
|
| 357 |
+
const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
|
| 358 |
+
scoreAnalysisModalContent.children().remove();
|
| 359 |
+
if (loadedSchedule.score == null) {
|
| 360 |
+
scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button.");
|
| 361 |
+
} else {
|
| 362 |
+
$('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
|
| 363 |
+
$.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) {
|
| 364 |
+
let constraints = scoreAnalysis.constraints;
|
| 365 |
+
constraints.sort((a, b) => {
|
| 366 |
+
let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
|
| 367 |
+
if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
|
| 368 |
+
if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
|
| 369 |
+
if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
|
| 370 |
+
return -1;
|
| 371 |
+
} else {
|
| 372 |
+
if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
|
| 373 |
+
if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
|
| 374 |
+
if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
|
| 375 |
+
return -1;
|
| 376 |
+
} else {
|
| 377 |
+
if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
|
| 378 |
+
if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
|
| 379 |
+
|
| 380 |
+
return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
});
|
| 384 |
+
constraints.map((e) => {
|
| 385 |
+
let components = getScoreComponents(e.weight);
|
| 386 |
+
e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
|
| 387 |
+
e.weight = components[e.type];
|
| 388 |
+
let scores = getScoreComponents(e.score);
|
| 389 |
+
e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
|
| 390 |
+
});
|
| 391 |
+
scoreAnalysis.constraints = constraints;
|
| 392 |
+
|
| 393 |
+
scoreAnalysisModalContent.children().remove();
|
| 394 |
+
scoreAnalysisModalContent.text("");
|
| 395 |
+
|
| 396 |
+
const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
|
| 397 |
+
const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
|
| 398 |
+
.append($(`<th></th>`))
|
| 399 |
+
.append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
|
| 400 |
+
.append($(`<th>Type</th>`))
|
| 401 |
+
.append($(`<th># Matches</th>`))
|
| 402 |
+
.append($(`<th>Weight</th>`))
|
| 403 |
+
.append($(`<th>Score</th>`))
|
| 404 |
+
.append($(`<th></th>`)));
|
| 405 |
+
analysisTable.append(analysisTHead);
|
| 406 |
+
const analysisTBody = $(`<tbody/>`)
|
| 407 |
+
$.each(scoreAnalysis.constraints, (index, constraintAnalysis) => {
|
| 408 |
+
let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
|
| 409 |
+
if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
|
| 410 |
+
|
| 411 |
+
let row = $(`<tr/>`);
|
| 412 |
+
row.append($(`<td/>`).html(icon))
|
| 413 |
+
.append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
|
| 414 |
+
.append($(`<td/>`).text(constraintAnalysis.type))
|
| 415 |
+
.append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
|
| 416 |
+
.append($(`<td/>`).text(constraintAnalysis.weight))
|
| 417 |
+
.append($(`<td/>`).text(constraintAnalysis.implicitScore));
|
| 418 |
+
analysisTBody.append(row);
|
| 419 |
+
row.append($(`<td/>`));
|
| 420 |
+
});
|
| 421 |
+
analysisTable.append(analysisTBody);
|
| 422 |
+
scoreAnalysisModalContent.append(analysisTable);
|
| 423 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 424 |
+
showError("Analyze failed.", xhr);
|
| 425 |
+
}, "text");
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
function getScoreComponents(score) {
|
| 430 |
+
let components = {hard: 0, medium: 0, soft: 0};
|
| 431 |
+
|
| 432 |
+
$.each([...score.matchAll(/(-?\d*(\.\d+)?)(hard|medium|soft)/g)], (i, parts) => {
|
| 433 |
+
components[parts[3]] = parseFloat(parts[1], 10);
|
| 434 |
+
});
|
| 435 |
+
|
| 436 |
+
return components;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
function refreshSolvingButtons(solving) {
|
| 440 |
+
if (solving) {
|
| 441 |
+
$("#solveButton").hide();
|
| 442 |
+
$("#stopSolvingButton").show();
|
| 443 |
+
if (autoRefreshIntervalId == null) {
|
| 444 |
+
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
| 445 |
+
}
|
| 446 |
+
} else {
|
| 447 |
+
$("#solveButton").show();
|
| 448 |
+
$("#stopSolvingButton").hide();
|
| 449 |
+
if (autoRefreshIntervalId != null) {
|
| 450 |
+
clearInterval(autoRefreshIntervalId);
|
| 451 |
+
autoRefreshIntervalId = null;
|
| 452 |
+
}
|
| 453 |
+
}
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
function stopSolving() {
|
| 457 |
+
$.delete(`/schedules/${scheduleId}`, function () {
|
| 458 |
+
refreshSolvingButtons(false);
|
| 459 |
+
refreshSchedule();
|
| 460 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 461 |
+
showError("Stop solving failed.", xhr);
|
| 462 |
+
});
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
function replaceQuickstartSolverForgeAutoHeaderFooter() {
|
| 466 |
+
const solverforgeHeader = $("header#solverforge-auto-header");
|
| 467 |
+
if (solverforgeHeader != null) {
|
| 468 |
+
solverforgeHeader.css("background-color", "#ffffff");
|
| 469 |
+
solverforgeHeader.append(
|
| 470 |
+
$(`<div class="container-fluid">
|
| 471 |
+
<nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
|
| 472 |
+
<a class="navbar-brand" href="https://www.solverforge.org">
|
| 473 |
+
<img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400">
|
| 474 |
+
</a>
|
| 475 |
+
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
| 476 |
+
<span class="navbar-toggler-icon"></span>
|
| 477 |
+
</button>
|
| 478 |
+
<div class="collapse navbar-collapse" id="navbarNav">
|
| 479 |
+
<ul class="nav nav-pills">
|
| 480 |
+
<li class="nav-item active" id="navUIItem">
|
| 481 |
+
<button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">Demo UI</button>
|
| 482 |
+
</li>
|
| 483 |
+
<li class="nav-item" id="navRestItem">
|
| 484 |
+
<button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">Guide</button>
|
| 485 |
+
</li>
|
| 486 |
+
<li class="nav-item" id="navOpenApiItem">
|
| 487 |
+
<button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">REST API</button>
|
| 488 |
+
</li>
|
| 489 |
+
</ul>
|
| 490 |
+
</div>
|
| 491 |
+
<div class="ms-auto">
|
| 492 |
+
<div class="dropdown">
|
| 493 |
+
<button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;">
|
| 494 |
+
Data
|
| 495 |
+
</button>
|
| 496 |
+
<div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
|
| 497 |
+
</div>
|
| 498 |
+
</div>
|
| 499 |
+
</nav>
|
| 500 |
+
</div>`));
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
const solverforgeFooter = $("footer#solverforge-auto-footer");
|
| 504 |
+
if (solverforgeFooter != null) {
|
| 505 |
+
solverforgeFooter.append(
|
| 506 |
+
$(`<footer class="bg-black text-white-50">
|
| 507 |
+
<div class="container">
|
| 508 |
+
<div class="hstack gap-3 p-4">
|
| 509 |
+
<div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
|
| 510 |
+
<div class="vr"></div>
|
| 511 |
+
<div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
|
| 512 |
+
<div class="vr"></div>
|
| 513 |
+
<div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
|
| 514 |
+
<div class="vr"></div>
|
| 515 |
+
<div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
|
| 516 |
+
</div>
|
| 517 |
+
</div>
|
| 518 |
+
</footer>`));
|
| 519 |
+
}
|
| 520 |
+
}
|
static/index.html
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<html lang="en">
|
| 2 |
+
<head>
|
| 3 |
+
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
|
| 4 |
+
<meta content="width=device-width, initial-scale=1" name="viewport">
|
| 5 |
+
<title>Employee scheduling - SolverForge for Python</title>
|
| 6 |
+
|
| 7 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
|
| 8 |
+
integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
|
| 9 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"/>
|
| 10 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"/>
|
| 11 |
+
<link rel="stylesheet" href="/webjars/solverforge/css/solverforge-webui.css"/>
|
| 12 |
+
<style>
|
| 13 |
+
.vis-time-axis .vis-grid.vis-saturday,
|
| 14 |
+
.vis-time-axis .vis-grid.vis-sunday {
|
| 15 |
+
background: #D3D7CFFF;
|
| 16 |
+
}
|
| 17 |
+
</style>
|
| 18 |
+
<link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
|
| 19 |
+
</head>
|
| 20 |
+
|
| 21 |
+
<body>
|
| 22 |
+
<header id="solverforge-auto-header">
|
| 23 |
+
<!-- Filled in by app.js -->
|
| 24 |
+
</header>
|
| 25 |
+
<div class="tab-content">
|
| 26 |
+
<div id="demo" class="tab-pane fade show active container-fluid">
|
| 27 |
+
<div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite"
|
| 28 |
+
aria-atomic="true">
|
| 29 |
+
<div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
|
| 30 |
+
</div>
|
| 31 |
+
<h1>Employee scheduling solver</h1>
|
| 32 |
+
<p>Generate the optimal schedule for your employees.</p>
|
| 33 |
+
|
| 34 |
+
<div class="mb-4">
|
| 35 |
+
<button id="solveButton" type="button" class="btn btn-success">
|
| 36 |
+
<span class="fas fa-play"></span> Solve
|
| 37 |
+
</button>
|
| 38 |
+
<button id="stopSolvingButton" type="button" class="btn btn-danger">
|
| 39 |
+
<span class="fas fa-stop"></span> Stop solving
|
| 40 |
+
</button>
|
| 41 |
+
<span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
|
| 42 |
+
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
|
| 43 |
+
|
| 44 |
+
<div class="float-end">
|
| 45 |
+
<ul class="nav nav-pills" role="tablist">
|
| 46 |
+
<li class="nav-item" role="presentation">
|
| 47 |
+
<button class="nav-link active" id="byLocationTab" data-bs-toggle="tab"
|
| 48 |
+
data-bs-target="#byLocationPanel" type="button" role="tab"
|
| 49 |
+
aria-controls="byLocationPanel" aria-selected="true">By location
|
| 50 |
+
</button>
|
| 51 |
+
</li>
|
| 52 |
+
<li class="nav-item" role="presentation">
|
| 53 |
+
<button class="nav-link" id="byEmployeeTab" data-bs-toggle="tab"
|
| 54 |
+
data-bs-target="#byEmployeePanel" type="button" role="tab"
|
| 55 |
+
aria-controls="byEmployeePanel" aria-selected="false">By employee
|
| 56 |
+
</button>
|
| 57 |
+
</li>
|
| 58 |
+
</ul>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="mb-4 tab-content">
|
| 62 |
+
<div class="tab-pane fade show active" id="byLocationPanel" role="tabpanel"
|
| 63 |
+
aria-labelledby="byLocationTab">
|
| 64 |
+
<div id="locationVisualization"></div>
|
| 65 |
+
</div>
|
| 66 |
+
<div class="tab-pane fade" id="byEmployeePanel" role="tabpanel" aria-labelledby="byEmployeeTab">
|
| 67 |
+
<div id="employeeVisualization"></div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div id="rest" class="tab-pane fade container-fluid">
|
| 73 |
+
<h1>REST API Guide</h1>
|
| 74 |
+
|
| 75 |
+
<h2>Employee Scheduling solver integration via cURL</h2>
|
| 76 |
+
|
| 77 |
+
<h3>1. Download demo data</h3>
|
| 78 |
+
<pre>
|
| 79 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 80 |
+
onclick="copyTextToClipboard('curl1')">Copy</button>
|
| 81 |
+
<code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data/SMALL -o sample.json</code>
|
| 82 |
+
</pre>
|
| 83 |
+
|
| 84 |
+
<h3>2. Post the sample data for solving</h3>
|
| 85 |
+
<p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
|
| 86 |
+
<pre>
|
| 87 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 88 |
+
onclick="copyTextToClipboard('curl2')">Copy</button>
|
| 89 |
+
<code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json</code>
|
| 90 |
+
</pre>
|
| 91 |
+
|
| 92 |
+
<h3>3. Get the current status and score</h3>
|
| 93 |
+
<pre>
|
| 94 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 95 |
+
onclick="copyTextToClipboard('curl3')">Copy</button>
|
| 96 |
+
<code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status</code>
|
| 97 |
+
</pre>
|
| 98 |
+
|
| 99 |
+
<h3>4. Get the complete solution</h3>
|
| 100 |
+
<pre>
|
| 101 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 102 |
+
onclick="copyTextToClipboard('curl4')">Copy</button>
|
| 103 |
+
<code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}</code>
|
| 104 |
+
</pre>
|
| 105 |
+
|
| 106 |
+
<h3>5. Terminate solving early</h3>
|
| 107 |
+
<pre>
|
| 108 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 109 |
+
onclick="copyTextToClipboard('curl5')">Copy</button>
|
| 110 |
+
<code id="curl5">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{id}</code>
|
| 111 |
+
</pre>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<div id="openapi" class="tab-pane fade container-fluid">
|
| 115 |
+
<h1>REST API Reference</h1>
|
| 116 |
+
<div class="ratio ratio-1x1">
|
| 117 |
+
<!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
|
| 118 |
+
<iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
<footer id="solverforge-auto-footer"></footer>
|
| 123 |
+
|
| 124 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
|
| 125 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js"></script>
|
| 126 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
| 127 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-joda/1.11.0/js-joda.min.js"></script>
|
| 128 |
+
<script src="/webjars/solverforge/js/solverforge-webui.js"></script>
|
| 129 |
+
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
|
| 130 |
+
integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
|
| 131 |
+
<script src="/app.js"></script>
|
| 132 |
+
</body>
|
| 133 |
+
</html>
|
static/webjars/solverforge/css/solverforge-webui.css
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
/* Keep in sync with .navbar height on a large screen. */
|
| 3 |
+
--ts-navbar-height: 109px;
|
| 4 |
+
|
| 5 |
+
--ts-green-1-rgb: #10b981;
|
| 6 |
+
--ts-green-2-rgb: #059669;
|
| 7 |
+
--ts-violet-1-rgb: #3E00FF;
|
| 8 |
+
--ts-violet-2-rgb: #3423A6;
|
| 9 |
+
--ts-violet-3-rgb: #2E1760;
|
| 10 |
+
--ts-violet-4-rgb: #200F4F;
|
| 11 |
+
--ts-violet-5-rgb: #000000; /* TODO FIXME */
|
| 12 |
+
--ts-violet-dark-1-rgb: #b6adfd;
|
| 13 |
+
--ts-violet-dark-2-rgb: #c1bbfd;
|
| 14 |
+
--ts-gray-rgb: #666666;
|
| 15 |
+
--ts-white-rgb: #FFFFFF;
|
| 16 |
+
--ts-light-rgb: #F2F2F2;
|
| 17 |
+
--ts-gray-border: #c5c5c5;
|
| 18 |
+
|
| 19 |
+
--tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
|
| 20 |
+
--bs-body-bg: var(--ts-light-rgb); /* link to html bg */
|
| 21 |
+
--bs-link-color: var(--ts-violet-1-rgb);
|
| 22 |
+
--bs-link-hover-color: var(--ts-violet-2-rgb);
|
| 23 |
+
|
| 24 |
+
--bs-navbar-color: var(--ts-white-rgb);
|
| 25 |
+
--bs-navbar-hover-color: var(--ts-white-rgb);
|
| 26 |
+
--bs-nav-link-font-size: 18px;
|
| 27 |
+
--bs-nav-link-font-weight: 400;
|
| 28 |
+
--bs-nav-link-color: var(--ts-white-rgb);
|
| 29 |
+
--ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
|
| 30 |
+
}
|
| 31 |
+
.btn {
|
| 32 |
+
--bs-btn-border-radius: 1.5rem;
|
| 33 |
+
}
|
| 34 |
+
.btn-primary {
|
| 35 |
+
--bs-btn-bg: var(--ts-violet-1-rgb);
|
| 36 |
+
--bs-btn-border-color: var(--ts-violet-1-rgb);
|
| 37 |
+
--bs-btn-hover-bg: var(--ts-violet-2-rgb);
|
| 38 |
+
--bs-btn-hover-border-color: var(--ts-violet-2-rgb);
|
| 39 |
+
--bs-btn-active-bg: var(--ts-violet-2-rgb);
|
| 40 |
+
--bs-btn-active-border-bg: var(--ts-violet-2-rgb);
|
| 41 |
+
--bs-btn-disabled-bg: var(--ts-violet-1-rgb);
|
| 42 |
+
--bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
|
| 43 |
+
}
|
| 44 |
+
.btn-outline-primary {
|
| 45 |
+
--bs-btn-color: var(--ts-violet-1-rgb);
|
| 46 |
+
--bs-btn-border-color: var(--ts-violet-1-rgb);
|
| 47 |
+
--bs-btn-hover-bg: var(--ts-violet-1-rgb);
|
| 48 |
+
--bs-btn-hover-border-color: var(--ts-violet-1-rgb);
|
| 49 |
+
--bs-btn-active-bg: var(--ts-violet-1-rgb);
|
| 50 |
+
--bs-btn-active-border-color: var(--ts-violet-1-rgb);
|
| 51 |
+
--bs-btn-disabled-color: var(--ts-violet-1-rgb);
|
| 52 |
+
--bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
|
| 53 |
+
}
|
| 54 |
+
.navbar-dark {
|
| 55 |
+
--bs-link-color: var(--ts-violet-dark-1-rgb);
|
| 56 |
+
--bs-link-hover-color: var(--ts-violet-dark-2-rgb);
|
| 57 |
+
--bs-navbar-color: var(--ts-white-rgb);
|
| 58 |
+
--bs-navbar-hover-color: var(--ts-white-rgb);
|
| 59 |
+
}
|
| 60 |
+
.nav-pills {
|
| 61 |
+
--bs-nav-pills-link-active-bg: var(--ts-green-1-rgb);
|
| 62 |
+
}
|
| 63 |
+
.nav-pills .nav-link:hover {
|
| 64 |
+
color: var(--ts-green-1-rgb);
|
| 65 |
+
}
|
| 66 |
+
.nav-pills .nav-link.active:hover {
|
| 67 |
+
color: var(--ts-white-rgb);
|
| 68 |
+
}
|
static/webjars/solverforge/img/solverforge-favicon.svg
ADDED
|
|
static/webjars/solverforge/img/solverforge-horizontal-white.svg
ADDED
|
|
static/webjars/solverforge/img/solverforge-horizontal.svg
ADDED
|
|
static/webjars/solverforge/img/solverforge-logo-stacked.svg
ADDED
|
|
static/webjars/solverforge/js/solverforge-webui.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function replaceSolverForgeAutoHeaderFooter() {
|
| 2 |
+
const solverforgeHeader = $("header#solverforge-auto-header");
|
| 3 |
+
if (solverforgeHeader != null) {
|
| 4 |
+
solverforgeHeader.addClass("bg-black")
|
| 5 |
+
solverforgeHeader.append(
|
| 6 |
+
$(`<div class="container-fluid">
|
| 7 |
+
<nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
|
| 8 |
+
<a class="navbar-brand" href="https://www.solverforge.org">
|
| 9 |
+
<img src="/solverforge/img/solverforge-horizontal-white.svg" alt="SolverForge logo" width="200">
|
| 10 |
+
</a>
|
| 11 |
+
</nav>
|
| 12 |
+
</div>`));
|
| 13 |
+
}
|
| 14 |
+
const solverforgeFooter = $("footer#solverforge-auto-footer");
|
| 15 |
+
if (solverforgeFooter != null) {
|
| 16 |
+
solverforgeFooter.append(
|
| 17 |
+
$(`<footer class="bg-black text-white-50">
|
| 18 |
+
<div class="container">
|
| 19 |
+
<div class="hstack gap-3 p-4">
|
| 20 |
+
<div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
|
| 21 |
+
<div class="vr"></div>
|
| 22 |
+
<div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
|
| 23 |
+
<div class="vr"></div>
|
| 24 |
+
<div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
|
| 25 |
+
<div class="vr"></div>
|
| 26 |
+
<div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
<div id="applicationInfo" class="container text-center"></div>
|
| 30 |
+
</footer>`));
|
| 31 |
+
|
| 32 |
+
applicationInfo();
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function showSimpleError(title) {
|
| 38 |
+
const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
|
| 39 |
+
.append($(`<div class="toast-header bg-danger">
|
| 40 |
+
<strong class="me-auto text-dark">Error</strong>
|
| 41 |
+
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
| 42 |
+
</div>`))
|
| 43 |
+
.append($(`<div class="toast-body"/>`)
|
| 44 |
+
.append($(`<p/>`).text(title))
|
| 45 |
+
);
|
| 46 |
+
$("#notificationPanel").append(notification);
|
| 47 |
+
notification.toast({delay: 30000});
|
| 48 |
+
notification.toast('show');
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function showError(title, xhr) {
|
| 52 |
+
var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
|
| 53 |
+
var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
|
| 54 |
+
var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
|
| 55 |
+
var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
|
| 56 |
+
|
| 57 |
+
if (xhr.responseJSON && !serverErrorMessage) {
|
| 58 |
+
serverErrorMessage = JSON.stringify(xhr.responseJSON);
|
| 59 |
+
serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
|
| 60 |
+
serverErrorId = `----`;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
|
| 64 |
+
const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
|
| 65 |
+
.append($(`<div class="toast-header bg-danger">
|
| 66 |
+
<strong class="me-auto text-dark">Error</strong>
|
| 67 |
+
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
| 68 |
+
</div>`))
|
| 69 |
+
.append($(`<div class="toast-body"/>`)
|
| 70 |
+
.append($(`<p/>`).text(title))
|
| 71 |
+
.append($(`<pre/>`)
|
| 72 |
+
.append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
|
| 73 |
+
)
|
| 74 |
+
);
|
| 75 |
+
$("#notificationPanel").append(notification);
|
| 76 |
+
notification.toast({delay: 30000});
|
| 77 |
+
notification.toast('show');
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// ****************************************************************************
|
| 81 |
+
// Application info
|
| 82 |
+
// ****************************************************************************
|
| 83 |
+
|
| 84 |
+
function applicationInfo() {
|
| 85 |
+
$.getJSON("info", function (info) {
|
| 86 |
+
$("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
|
| 87 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 88 |
+
console.warn("Unable to collect application information");
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// ****************************************************************************
|
| 93 |
+
// TangoColorFactory
|
| 94 |
+
// ****************************************************************************
|
| 95 |
+
|
| 96 |
+
const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
|
| 97 |
+
const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
|
| 98 |
+
|
| 99 |
+
var colorMap = new Map;
|
| 100 |
+
var nextColorCount = 0;
|
| 101 |
+
|
| 102 |
+
function pickColor(object) {
|
| 103 |
+
let color = colorMap[object];
|
| 104 |
+
if (color !== undefined) {
|
| 105 |
+
return color;
|
| 106 |
+
}
|
| 107 |
+
color = nextColor();
|
| 108 |
+
colorMap[object] = color;
|
| 109 |
+
return color;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function nextColor() {
|
| 113 |
+
let color;
|
| 114 |
+
let colorIndex = nextColorCount % SEQUENCE_1.length;
|
| 115 |
+
let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
|
| 116 |
+
if (shadeIndex === 0) {
|
| 117 |
+
color = SEQUENCE_1[colorIndex];
|
| 118 |
+
} else if (shadeIndex === 1) {
|
| 119 |
+
color = SEQUENCE_2[colorIndex];
|
| 120 |
+
} else {
|
| 121 |
+
shadeIndex -= 3;
|
| 122 |
+
let floorColor = SEQUENCE_2[colorIndex];
|
| 123 |
+
let ceilColor = SEQUENCE_1[colorIndex];
|
| 124 |
+
let base = Math.floor((shadeIndex / 2) + 1);
|
| 125 |
+
let divisor = 2;
|
| 126 |
+
while (base >= divisor) {
|
| 127 |
+
divisor *= 2;
|
| 128 |
+
}
|
| 129 |
+
base = (base * 2) - divisor + 1;
|
| 130 |
+
let shadePercentage = base / divisor;
|
| 131 |
+
color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
|
| 132 |
+
}
|
| 133 |
+
nextColorCount++;
|
| 134 |
+
return "#" + color.toString(16);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
|
| 138 |
+
let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
|
| 139 |
+
let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
|
| 140 |
+
let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
|
| 141 |
+
return red | green | blue;
|
| 142 |
+
}
|
tests/__pycache__/test_constraints.cpython-310-pytest-8.2.2.pyc
ADDED
|
Binary file (5.55 kB). View file
|
|
|
tests/__pycache__/test_constraints.cpython-311-pytest-8.2.2.pyc
ADDED
|
Binary file (16.6 kB). View file
|
|
|
tests/__pycache__/test_constraints.cpython-312-pytest-8.2.2.pyc
ADDED
|
Binary file (15 kB). View file
|
|
|
tests/__pycache__/test_feasible.cpython-310-pytest-8.2.2.pyc
ADDED
|
Binary file (2.28 kB). View file
|
|
|
tests/__pycache__/test_feasible.cpython-311-pytest-8.2.2.pyc
ADDED
|
Binary file (4.65 kB). View file
|
|
|