200ok shiftplanning - Optimize your work force schedule
Table of Contents
- 1. 200ok shiftplanning
- 2. Introduction
- 3. Use cases
- 3.1. Optimize for employees (Classic shift operation)
- 3.2. Optimize for spots (Service provider)
- 3.3. Optional rules
- 3.4. Description of the rules
- 3.4.1. Context switch on same day
- 3.4.2. Daily Minutes exceed contract maximum
- 3.4.3. Weekly minutes exceed contract maximum
- 3.4.4. Monthly minutes exceed contract maximum
- 3.4.5. Yearly minutes exceed contract maximum
- 3.4.6. Employee has overlapping shifts
- 3.4.7. Employee assigned to shift but unavailable
- 3.4.8. Employee assigned to shift desired
- 3.4.9. Employee assigned to shift undesired
- 3.4.10. Employee lacks required skill for shift
- 3.4.11. Employee lacks required skill for spot
- 3.4.12. Locked employee was changed
- 3.4.13. Minutes exceed spot maximum
- 3.4.14. Shift remains unassigned
- 3.4.15. Spot remains unassigned
- 3.4.16. Weekly minutes exceed skill maximum
- 4. API
- 5. Appendix
1. 200ok shiftplanning
This is the service documentation for the 200ok Gmbh product 200ok shiftplanning. It is intended for developers working with or on 200ok shiftplanning.
1.1. Feedback
Please let us know about gaps or errors in our documentation at info@200ok.ch.
2. Introduction
200ok shiftplanning is a modern data-driven shift planning solution that drives the efficiency of it's customers through its stateless API solution.
The concept of 200ok shiftplanning is quite easy: Given a set of
credentials and a configured tenant, you can generate a Roster
with
planned shifts in just one single API call.
200ok has been founded by two former lecturers of the university of applied sciences of Zurich. During this time, they have acquired a lot of knowledge in the area of NP hard and NP complete problems and have supervised a series of related papers. Some of these papers are:
- Evaluation of solving the nurse-rostering-problem in web applications
- An explorative study into the realm of On-Device Machine Training
- Linked data-driven UI for task-based computing
- Comparing traditional statistical methods with machine learning
- Generic API to process fundamental computer science problems
- Evaluating concepts of NP-complete problems for web applications
- Evaluation on bringing business rules from customers into a web application
Apart from these papers, 200ok has been involved with providing products for shift planning since 2014 and hence has a lot of practical experience on the topic. 200ok shiftplanning is the culmination of decades of experience solving hard business problems and data driven API development.
2.1. Overview Domain Model
The following list gives a rough overview over the domain model. The application of the models depends on the specific use case.
- Roster (a list of employees and associated information for a given time period)
- Shift (a scheduled period of work or duty)
- Employee
- Spot (a work space, area, customer, or subject)
- Skill (the ability or permission to perform work in a given shift or spot)
- Availability (of an employee)
- Contract (time constraints for an employee)
Figure 1: An entity relationship diagram showing the domain model
3. Use cases
To optimize a Roster
, you create a Job
of a specific type
. The
type
depends on what resource you want to optimize for.
3.1. Optimize for employees (Classic shift operation)
In a classic shift operation, during optimization, shifts are
assigned to employees. Hence, the shiftplanning job type
is
called optimize_for_employees
. For an example how to create a
roster for this, see create roster.
3.1.1. Rules
To calculate a Roster
with planned shifts, there have to be
rules. Here is the list of rules that 200ok shiftplanning uses
by for Job type
optimize_for_employees
. These rules can, of
course, be adapted to your specific business and to your wishes.
We are also happy to listen to your requirements and add rules
specific to your business.
Rules are always formulated for the negative case. The reason is that our algorithm will give penalty points for every rule (constraint) that cannot be fulfilled.
- employee lacks required skill for shift - employee lacks required skill for spot - employee has overlapping shifts - daily minutes exceed contract maximum - weekly minutes exceed contract maximum - monthly minutes exceed contract maximum - yearly minutes exceed contract maximum - shift remains unassigned - locked employee was changed - employee assigned to shift but unavailable - weekly minutes exceed skill maximum - employee assigned to shift undesired - employee assigned to shift desired
3.1.2. Budgeting per Skill
Optionally, if you want employees to work a maximum of time on a
certain task, you can set a time budget per Skill
.
Example use-case: A support department wants their employees to work
on different Spot
\s for a maximum amount of time - 2 days a week on
the hotline, 2 days a week on second-level support and finally one day
a week giving trainings to customers. Achieve this kind of
budgeting by setting maximumMinutesPerWeekPerEmployee
on the
relevant Skill
.
3.2. Optimize for spots (Service provider)
Service providers classically aren't shift operations. However,
they have customers with budgets. The problem during shift planning
is to assign the shifts to those customers (i.e. campaigns,
locations, etc.). This can be achieved by providing Shifts
that
have pre-assigned Employees
, but are lacking the Spot
. Spots
have a time budget assigned through maximumMinutes
. The
shiftplanning job type
is called optimize_for_spots
.
Here is a full example on how to create a roster with
optimize_for_spots
.
3.2.1. Create Roster
Here's an example Roster
that has pre-assigned shifts that are
lacking a Spot
.
{ "meta": { "ruleFilePaths": [ "rosterConstraintsOptimizeForSpot.drl" ], "secondsSpentLimit": 10 }, "skills": [ { "id": "reiskochen", "name": "Reis kochen" }, { "id": "reiswaschen", "name": "Reis waschen" } ], "spots": [ { "id": "reiskocher", "name": "Reiskocher", "maximumMinutes": 180, "requiredSkills": [ "reiskochen" ] }, { "id": "lavabo", "name": "Lavabo", "maximumMinutes": 240, "requiredSkills": [ "reiswaschen" ] } ], "contracts": [ { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerMonth": 0, "maximumMinutesPerYear": 0 } ], "employees": [ { "id": "mrsrice", "name": "Mrs Rice", "contract": "fulltime", "skills": [ "reiskochen", "reiswaschen" ] }, { "id": "mrrice", "name": "Mr Rice", "contract": "fulltime", "skills": [ "reiswaschen" ] } ], "shifts": [ { "id": "82c397c6-8d58-4329-9c94-69d3637b6b1b", "employee": "mrsrice", "startDateTime": "2021-06-23T08:00", "endDateTime": "2021-06-23T12:00" }, { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "employee": "mrsrice", "startDateTime": "2021-06-23T14:00", "endDateTime": "2021-06-23T17:00" }, { "id": "5eb45659-f88f-4634-a925-3d5ef706199f", "employee": "mrrice", "startDateTime": "2021-06-23T08:00", "endDateTime": "2021-06-23T12:00" } ] }
echo $example_roster | http $host/v1/tenants/$tenant_id/jobs \ Authorization:"Token ${auth_token}" \ webhook==$webhook_url \ type==optimize_for_spots
{"msg":"Job accepted.","webhook":"http://your-domain.ch/webhook","job_id":"875016f8-41fb-4ad2-851f-59900b471749"}
When the job is finished, the resulting Job
will be POSTed to
http://your-domain.ch/webhook
. It will include some
metadata. The actual Roster
will be nested under the key
results
. The Roster
, again, will include some metadata and
planned shifts indicated by the employee
id per Shift
.
This is what it looks like:
{ "payload": { "meta": { "ruleFilePaths": [ "rosterConstraintsOptimizeForSpot.drl" ], "secondsSpentLimit": 10 }, "spots": [ { "id": "reiskocher", "name": "Reiskocher", "maximumMinutes": 180, "requiredSkills": [ "reiskochen" ] }, { "id": "lavabo", "name": "Lavabo", "maximumMinutes": 240, "requiredSkills": [ "reiswaschen" ] } ], "shifts": [ { "id": "82c397c6-8d58-4329-9c94-69d3637b6b1b", "employee": "mrsrice", "endDateTime": "2021-06-23T12:00", "startDateTime": "2021-06-23T08:00" }, { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "employee": "mrsrice", "endDateTime": "2021-06-23T17:00", "startDateTime": "2021-06-23T14:00" }, { "id": "5eb45659-f88f-4634-a925-3d5ef706199f", "employee": "mrrice", "endDateTime": "2021-06-23T12:00", "startDateTime": "2021-06-23T08:00" } ], "skills": [ { "id": "reiskochen", "name": "Reis kochen" }, { "id": "reiswaschen", "name": "Reis waschen" } ], "contracts": [ { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerYear": 0, "maximumMinutesPerMonth": 0 } ], "employees": [ { "id": "mrsrice", "name": "Mrs Rice", "skills": [ "reiskochen", "reiswaschen" ], "contract": "fulltime" }, { "id": "mrrice", "name": "Mr Rice", "skills": [ "reiswaschen" ], "contract": "fulltime" } ] }, "started_at": "2022-01-25T15:33:08.196Z", "completed_at": "2022-01-25T15:33:20.767Z", "webhook": "https://webhook.site/e67d9837-5ae0-468f-8e04-04bf34bc87ea", "indictment": { "charge-0": { "justification": { "id": "5eb45659-f88f-4634-a925-3d5ef706199f", "spot": null, "startDateTime": "2021-06-23T08:00", "endDateTime": "2021-06-23T12:00", "requiredSkills": null, "employee": { "id": "mrrice", "name": "Mr Rice", "contract": { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerMonth": 0, "maximumMinutesPerYear": 0 }, "skills": [ { "id": "reiswaschen", "name": "Reis waschen" } ] }, "lockedEmployee": null }, "constraintMatchSet": [ { "constraintName": "spotRemainsUnassigned", "justificationList": [ { "id": "5eb45659-f88f-4634-a925-3d5ef706199f", "spot": null, "startDateTime": "2021-06-23T08:00", "endDateTime": "2021-06-23T12:00", "requiredSkills": null, "employee": { "id": "mrrice", "name": "Mr Rice", "contract": { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerMonth": 0, "maximumMinutesPerYear": 0 }, "skills": [ { "id": "reiswaschen", "name": "Reis waschen" } ] }, "lockedEmployee": null } ], "score": { "initScore": 0, "hardScore": 0, "softScore": -240, "feasible": true, "solutionInitialized": true }, "constraintId": "defaultpkg/spotRemainsUnassigned" } ], "score": { "initScore": 0, "hardScore": 0, "softScore": -240, "feasible": true, "solutionInitialized": true }, "constraintMatchCount": 1 } }, "type": "optimize_for_spots", "state": "complete", "id": 142, "tenant_id": 1, "uuid": "b50d871c-2194-49c1-8d6a-6a2ce78fb2dc", "created_at": "2022-01-25T15:33:08.169Z", "results": [ { "id": 197, "job_id": 142, "payload": { "meta": { "entityClass": "shift_for_spot", "ruleFilePaths": [ "rosterConstraintsOptimizeForSpot.drl" ], "secondsSpentLimit": 10 }, "score": { "feasible": true, "hardScore": 0, "initScore": 0, "softScore": -240, "solutionInitialized": true }, "spots": [ { "id": "reiskocher", "name": "Reiskocher", "maximumMinutes": 180, "requiredSkills": [ "reiskochen" ] }, { "id": "lavabo", "name": "Lavabo", "maximumMinutes": 240, "requiredSkills": [ "reiswaschen" ] } ], "shifts": [ { "id": "82c397c6-8d58-4329-9c94-69d3637b6b1b", "spot": "lavabo", "type": "shift_for_spot", "employee": "mrsrice", "endDateTime": "2021-06-23T12:00", "startDateTime": "2021-06-23T08:00", "lockedEmployee": null, "requiredSkills": null }, { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "spot": "reiskocher", "type": "shift_for_spot", "employee": "mrsrice", "endDateTime": "2021-06-23T17:00", "startDateTime": "2021-06-23T14:00", "lockedEmployee": null, "requiredSkills": null }, { "id": "5eb45659-f88f-4634-a925-3d5ef706199f", "spot": null, "type": "shift_for_spot", "employee": "mrrice", "endDateTime": "2021-06-23T12:00", "startDateTime": "2021-06-23T08:00", "lockedEmployee": null, "requiredSkills": null } ], "skills": [ { "id": "reiskochen", "name": "Reis kochen" }, { "id": "reiswaschen", "name": "Reis waschen" } ], "contracts": [ { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerYear": 0, "maximumMinutesPerMonth": 0 } ], "employees": [ { "id": "mrsrice", "name": "Mrs Rice", "skills": [ "reiskochen", "reiswaschen" ], "contract": "fulltime" }, { "id": "mrrice", "name": "Mr Rice", "skills": [ "reiswaschen" ], "contract": "fulltime" } ], "availabilities": [] }, "final": true, "created_at": "2022-01-25T15:33:20.757Z" } ] }
3.2.2. Rules
To calculate a Roster
with planned shifts, there have to be
rules. Here is the list of rules that 200ok shiftplanning uses
by for Job type
optimize_for_spots
. These rules can, of
course, be adapted to your specific business and to your wishes.
We are also happy to listen to your requirements and add rules
specific to your business.
Rules are always formulated for the negative case. The reason is that our algorithm will give penalty points for every rule (constraint) that cannot be fulfilled.
- employee lacks required skill for shift - employee lacks required skill for spot - minutes exceed spot maximum - weekly minutes exceed skill maximum - spot remains unassigned - employee assigned to shift undesired - employee assigned to shift desired
3.3. Optional rules
There are some rules that can optionally be enabled for every use case:
- context switch on same day
3.4. Description of the rules
There are hard and soft constraints. The algorithm primarily
optimizes for hard constraints and only secondary for soft constraints.
If any one hard constraint is not met, the solution will be marked as
infeasible
.
3.4.1. Context switch on same day
Employees should not need to switch spots within a day. Switching spots is considered a context switch, which in some cases should be minimized.
A match yields soft penalty points equal to the mean minutes of conflicting shifts.
3.4.2. Daily Minutes exceed contract maximum
If the employee's contract defines a maximumMinutesPerDay > 0
, the
sum of all shifts within a day assigned to the employee must not
exceed the given number.
A match yields hard penalty points equal to the excess minutes divided by the number of affected shifts.
3.4.3. Weekly minutes exceed contract maximum
If the employee's contract defines a maximumMinutesPerWeek > 0
, the
sum of all shifts within a week assigned to the employee must not
exceed the given number.
A match yields hard penalty points equal to the excess minutes divided by the number of affected shifts.
3.4.4. Monthly minutes exceed contract maximum
If the employee's contract defines a maximumMinutesPerMonth > 0
, the
sum of all shifts within a month assigned to the employee must not
exceed the given number.
A match yields hard penalty points equal to the excess minutes divided by the number of affected shifts.
3.4.5. Yearly minutes exceed contract maximum
If the employee's contract defines a maximumMinutesPerYear > 0
, the
sum of all shifts within a year assigned to the employee must not
exceed the given number.
A match yields hard penalty points equal to the excess minutes divided by the number of affected shifts.
3.4.6. Employee has overlapping shifts
No two shifts assigned to an employee may overlap.
A match yields hard penalty points equal to the sum of minutes of affected shifts.
3.4.8. Employee assigned to shift desired
Employees should have shifts assigned that cover the desired availabilities (availabilities of state "DESIRED").
A match yields soft bonus points equal to the minutes of the affected availability of state DESIRED.
3.4.9. Employee assigned to shift undesired
Employees should not have shifts assigned that cover the unsdesired availability (availabilities of state "UNDESIRED").
A match yields soft penalty points equal to the minutes of the violated availability of state UNDESIRED.
3.4.10. Employee lacks required skill for shift
Employees should not be assigned to shifts that require a certain skill if the employee does not have the skill.
A match yields hard penalty points equal to the minutes of the affected shift.
3.4.11. Employee lacks required skill for spot
Employees should not be assigned to shifts at a given spot if the spot requires a certain skill and the employee does not have the skill.
A match yields hard penalty points equal to the minutes of the affected shift.
3.4.12. Locked employee was changed
If a shift is locked to an employee it should not be assigend to a different employee.
When using the field lockedEmployee
on a shift, the same employee
must be set on the field employee
on that shift.
A match yields hard penalty points equal to the minutes of the affected shift.
3.4.13. Minutes exceed spot maximum
If a spot has maximumMinutes > 0
the sum of shifts in that spot may
not exceed the given value.
A match yields hard penalty points equal to the excess minutes.
3.4.14. Shift remains unassigned
Misnomer, better: "Shift remains unassigned to employee"
When optimizing for shifts for employees, no shift should remain unassigned to an employee.
A match yields hard penalty points equal to half the minutes of the affected shift. (The penalty points are half, because an unassigend shift is considered the "best kind of infeasable". E.g. better than an unskilled worker assigned.)
3.4.15. Spot remains unassigned
Misnomer, better: "Shift remains unassigned to spot"
When optimizing shifts for spots, no shift should remain unassigend to a spot. Having said so, shifts that are not assigned to Spots are OK. That only means that there are not enough spots (projects/campaigns/customers/…) with budget available. This rule ensures that Shifts will not be assigned to a Spot if there is no budget available.
A match yields hard penalty points equal to the minutes of the affected shift.
3.4.16. Weekly minutes exceed skill maximum
If a skill has maximumMinutesPerWeekPerEmployee > 0
the sum of all
shifts for which the skill is required within a week must not execeed
the given number for any employee.
A match yields hard penalty points equal to the excess minutes divided by the number of affected shifts.
4. API
The API of 200ok shiftplanning is documented with Swagger and available at https://compute-demo.200ok.ch.
The following examples use httpie or curl to create http requests. The examples always have two code blocks: First the httpie or curl command, then immediately the result.
4.1. Authentication
This API employs authentication and authorization. You will get appropriate 401 (unauthorized) and 403 (forbidden) status codes. To make authenticated requestes, you first login using basic auth. This will yield a JWT Token that can you will use for subsequent requests in a "Authorization" header.
Here is how to login with basic auth using common commandline tools:
- curl:
curl --basic -u username:password https://compute-demo.200ok.ch/v1/login
- httpie:
http --auth username:password https://compute-demo.200ok.ch/v1/login
If you want to generate a basic auth token for use with this Swagger
API, you can use your favorite programming language. Here's an example
in shell: echo -n "Basic " $(echo -n "username:password" | base64)
This will generate something like Basic dXNlcjI6a29pcmExMg==
which you
then can use as authorization header to retrieve a JWT token. JWT
tokens expire after 2h. Example:
curl -H "Authorization: Basic dXNlcjI6a29pcmExMg=
" https://compute-demo.200ok.ch/v1/login=
After logging in via basic auth, the retrieved JWT token should be used for endpoints that require authentication. Here's an example:
curl -H "Authorization: Token eyJhbGc...2Bm63IBw" https://compute-demo.200ok.ch/v1/tenants
4.1.1. httpie
http --ignore-stdin --pretty format --auth user1:kissa13 $host/v1/login
{ "message": "Basic auth succeeded!", "user": { "id": 1, "roles": [ "admin", "user" ], "token": "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6MSwicm9sZXMiOlsiYWRtaW4iLCJ1c2VyIl0sImV4cCI6MTYzNzc2OTYyOX0.YjPQnzweBnzwVaRfo6QMfLzbvyf3xX2KJR7CtOJkBMYdiDRQA27Gj2I_pR0GXGl_Id87y5teGwUNoZDk60BnNw", "username": "user1" } }
4.1.2. curl
curl --basic -u user1:kissa13 $host/v1/login | jq '.'
{ "message": "Basic auth succeeded!", "user": { "username": "user1", "id": 1, "roles": [ "admin", "user" ], "token": "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6MSwicm9sZXMiOlsiYWRtaW4iLCJ1c2VyIl0sImV4cCI6MTYzODg5NDAyM30.9E1Wfyc_3iNn1IPnvrnU5qUV9Y4UnwE5UbG9uVRudrqq6gK4qbaS_425qOF-bBHZum0YvasDdUD9rbjdN9ZF5g" } }
4.2. Tenant
http --ignore-stdin $host/v1/tenants \ Authorization:"Token ${auth_token}" \ name="tenant 1"
{"id":118,"name":"tenant 1"}
4.3. Roster
To create a Roster
with the shifts planned, simply POST a
Roster
where the shifts are not yet planned. Optionally, if you
want to send a partially planned Roster
, instead, you can.
Find the schema definition of Roster
in the appendix or directly
in the Swagger API documentation: https://compute-demo.200ok.ch/index.html#/roster/post_v1_tenants__tenant_id__compute_roster.
Here's an example Roster
with no planned shifts:
{ "meta": { "ruleFilePaths": [ "rosterConstraints.drl" ], "secondsSpentLimit": 10 }, "skills": [ { "id": "reiskochen", "name": "Reis kochen" }, { "id": "reiswaschen", "name": "Reis waschen" } ], "spots": [ { "id": "reiskocher", "name": "Reiskocher", "requiredSkills": [ "reiskochen" ] }, { "id": "lavabo", "name": "Lavabo", "requiredSkills": [ "reiswaschen" ] } ], "contracts": [ { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerMonth": 0, "maximumMinutesPerYear": 0 } ], "employees": [ { "id": "mrsrice", "name": "Mrs Rice", "contract": "fulltime", "skills": [ "reiskochen", "reiswaschen" ] }, { "id": "mrrice", "name": "Mr Rice", "contract": "fulltime", "skills": [ "reiswaschen" ] } ], "shifts": [ { "id": "82c397c6-8d58-4329-9c94-69d3637b6b1b", "spot": "reiskocher", "startDateTime": "2021-06-23T09:00", "endDateTime": "2021-06-23T10:00" }, { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "spot": "reiskocher", "startDateTime": "2021-06-23T09:30", "endDateTime": "2021-06-23T10:30" }, { "id": "3e1fec47-003e-4d9f-a0b7-4d49405b2dee", "spot": "lavabo", "startDateTime": "2021-06-23T08:30", "endDateTime": "2021-06-23T09:00" } ] }
4.3.1. Create Roster
To optimize a Roster
, you create a Job
of a specific type
.
The type
depends on what resource you want to optimize for.
Let's first look at the case of a classic shift operation. In a
classic shift operation, during optimization, shifts are assigned
to employees. Hence, the Job type
is called optimize_for_employees
.
NB: For a complete list of available job types, please see job types.
Here is an example given the above example Roster
.
echo $example_roster | http $host/v1/tenants/$tenant_id/jobs \ Authorization:"Token ${auth_token}" \ webhook==$webhook_url \ type==optimize_for_employees
{"msg":"Job accepted.","webhook":"http://your-domain.ch/webhook","job_id":"cfc501ad-4b17-4c55-beed-4e2f5b59eb54"}
When the job is finished, the resulting Job
will be POSTed to
http://your-domain.ch/webhook
. It will include some
metadata. The actual Roster
will be nested under the key
results
. The Roster
, again, will include some metadata and
planned shifts indicated by the employee
id per Shift
.
This is what it looks like:
{ "payload": { "meta": { "ruleFilePaths": [ "rosterConstraints.drl" ], "secondsSpentLimit": 10 }, "spots": [ { "id": "reiskocher", "name": "Reiskocher", "requiredSkills": [ "reiskochen" ] }, { "id": "lavabo", "name": "Lavabo", "requiredSkills": [ "reiswaschen" ] } ], "shifts": [ { "id": "82c397c6-8d58-4329-9c94-69d3637b6b1b", "spot": "reiskocher", "endDateTime": "2021-06-23T10:00", "startDateTime": "2021-06-23T09:00" }, { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "spot": "reiskocher", "endDateTime": "2021-06-23T10:30", "startDateTime": "2021-06-23T09:30" }, { "id": "3e1fec47-003e-4d9f-a0b7-4d49405b2dee", "spot": "lavabo", "endDateTime": "2021-06-23T09:00", "startDateTime": "2021-06-23T08:30" } ], "skills": [ { "id": "reiskochen", "name": "Reis kochen" }, { "id": "reiswaschen", "name": "Reis waschen" } ], "contracts": [ { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerYear": 0, "maximumMinutesPerMonth": 0 } ], "employees": [ { "id": "mrsrice", "name": "Mrs Rice", "skills": [ "reiskochen", "reiswaschen" ], "contract": "fulltime" }, { "id": "mrrice", "name": "Mr Rice", "skills": [ "reiswaschen" ], "contract": "fulltime" } ] }, "started_at": "2022-01-25T16:31:12.762Z", "completed_at": "2022-01-25T16:31:25.287Z", "webhook": "https://webhook.site/e67d9837-5ae0-468f-8e04-04bf34bc87ea", "indictment": { "charge-0": { "justification": { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "spot": { "id": "reiskocher", "name": "Reiskocher", "maximumMinutes": 0, "requiredSkills": [ { "id": "reiskochen", "name": "Reis kochen" } ] }, "startDateTime": "2021-06-23T09:30", "endDateTime": "2021-06-23T10:30", "requiredSkills": null, "employee": { "id": "mrrice", "name": "Mr Rice", "contract": { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerMonth": 0, "maximumMinutesPerYear": 0 }, "skills": [ { "id": "reiswaschen", "name": "Reis waschen" } ] }, "lockedEmployee": null }, "constraintMatchSet": [ { "constraintName": "employeeLacksRequiredSkillForSpot", "justificationList": [ { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "spot": { "id": "reiskocher", "name": "Reiskocher", "maximumMinutes": 0, "requiredSkills": [ { "id": "reiskochen", "name": "Reis kochen" } ] }, "startDateTime": "2021-06-23T09:30", "endDateTime": "2021-06-23T10:30", "requiredSkills": null, "employee": { "id": "mrrice", "name": "Mr Rice", "contract": { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerMonth": 0, "maximumMinutesPerYear": 0 }, "skills": [ { "id": "reiswaschen", "name": "Reis waschen" } ] }, "lockedEmployee": null } ], "score": { "initScore": 0, "hardScore": -60, "softScore": 0, "feasible": false, "solutionInitialized": true }, "constraintId": "defaultpkg/employeeLacksRequiredSkillForSpot" } ], "score": { "initScore": 0, "hardScore": -60, "softScore": 0, "feasible": false, "solutionInitialized": true }, "constraintMatchCount": 1 } }, "type": "optimize_for_employees", "state": "complete", "id": 147, "tenant_id": 1, "uuid": "bcdc1f19-c788-4379-bd6f-d03f4f87c702", "created_at": "2022-01-25T16:31:12.651Z", "results": [ { "id": 209, "job_id": 147, "payload": { "meta": { "ruleFilePaths": [ "rosterConstraints.drl" ], "secondsSpentLimit": 10 }, "score": { "feasible": false, "hardScore": -60, "initScore": 0, "softScore": 0, "solutionInitialized": true }, "spots": [ { "id": "reiskocher", "name": "Reiskocher", "maximumMinutes": 0, "requiredSkills": [ "reiskochen" ] }, { "id": "lavabo", "name": "Lavabo", "maximumMinutes": 0, "requiredSkills": [ "reiswaschen" ] } ], "shifts": [ { "id": "82c397c6-8d58-4329-9c94-69d3637b6b1b", "spot": "reiskocher", "employee": "mrsrice", "endDateTime": "2021-06-23T10:00", "startDateTime": "2021-06-23T09:00", "lockedEmployee": null, "requiredSkills": null }, { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "spot": "reiskocher", "employee": "mrrice", "endDateTime": "2021-06-23T10:30", "startDateTime": "2021-06-23T09:30", "lockedEmployee": null, "requiredSkills": null }, { "id": "3e1fec47-003e-4d9f-a0b7-4d49405b2dee", "spot": "lavabo", "employee": "mrsrice", "endDateTime": "2021-06-23T09:00", "startDateTime": "2021-06-23T08:30", "lockedEmployee": null, "requiredSkills": null } ], "skills": [ { "id": "reiskochen", "name": "Reis kochen" }, { "id": "reiswaschen", "name": "Reis waschen" } ], "contracts": [ { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerYear": 0, "maximumMinutesPerMonth": 0 } ], "employees": [ { "id": "mrsrice", "name": "Mrs Rice", "skills": [ "reiskochen", "reiswaschen" ], "contract": "fulltime" }, { "id": "mrrice", "name": "Mr Rice", "skills": [ "reiswaschen" ], "contract": "fulltime" } ], "availabilities": [] }, "final": true, "created_at": "2022-01-25T16:31:25.276Z" } ] }
4.3.2. Validate Roster
To validate a Roster
against the given rules, you create a Job
of type
score_roster
. Here is an example given the above
example Roster
.
echo $example_roster | http $host/v1/tenants/$tenant_id/jobs/ \ Authorization:"Token ${auth_token}" \ webhook==$webhook_url \ type==score_roster
{"msg":"Job accepted.","webhook":"https://webhook.site/3d45188b-2932-4f37-8115-b65cfaadba26","job_id":"dfe1209d-7fad-4582-9e6c-b7551a037542"}
This is what the payload will look like. It includes the key
indictment
which has all the information regarding the given
rules. Each of them has various metadata including a score
. The
top level also includes the key results
which includes your
given Roster
, but has the overall score
updated.
{ "payload": { "meta": { "ruleFilePaths": [ "rosterConstraints.drl" ], "secondsSpentLimit": 10 }, "spots": [ { "id": "reiskocher", "name": "Reiskocher", "requiredSkills": [ "reiskochen" ] }, { "id": "lavabo", "name": "Lavabo", "requiredSkills": [ "reiswaschen" ] } ], "shifts": [ { "id": "82c397c6-8d58-4329-9c94-69d3637b6b1b", "spot": "reiskocher", "endDateTime": "2021-06-23T10:00", "startDateTime": "2021-06-23T09:00" }, { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "spot": "reiskocher", "endDateTime": "2021-06-23T10:30", "startDateTime": "2021-06-23T09:30" }, { "id": "3e1fec47-003e-4d9f-a0b7-4d49405b2dee", "spot": "lavabo", "endDateTime": "2021-06-23T09:00", "startDateTime": "2021-06-23T08:30" } ], "skills": [ { "id": "reiskochen", "name": "Reis kochen" }, { "id": "reiswaschen", "name": "Reis waschen" } ], "contracts": [ { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerYear": 0, "maximumMinutesPerMonth": 0 } ], "employees": [ { "id": "mrsrice", "name": "Mrs Rice", "skills": [ "reiskochen", "reiswaschen" ], "contract": "fulltime" }, { "id": "mrrice", "name": "Mr Rice", "skills": [ "reiswaschen" ], "contract": "fulltime" } ] }, "started_at": "2021-12-02T15:56:25.367Z", "completed_at": "2021-12-02T15:56:28.047Z", "webhook": "https://webhook.site/3d45188b-2932-4f37-8115-b65cfaadba26", "indictment": { "charge-0": { "justification": { "id": "82c397c6-8d58-4329-9c94-69d3637b6b1b", "spot": { "id": "reiskocher", "name": "Reiskocher", "requiredSkills": [ { "id": "reiskochen", "name": "Reis kochen" } ] }, "startDateTime": "2021-06-23T09:00", "endDateTime": "2021-06-23T10:00", "requiredSkills": null, "employee": null, "lockedEmployee": null }, "constraintMatchSet": [ { "constraintName": "shiftRemainsUnassigned", "justificationList": [ { "id": "82c397c6-8d58-4329-9c94-69d3637b6b1b", "spot": { "id": "reiskocher", "name": "Reiskocher", "requiredSkills": [ { "id": "reiskochen", "name": "Reis kochen" } ] }, "startDateTime": "2021-06-23T09:00", "endDateTime": "2021-06-23T10:00", "requiredSkills": null, "employee": null, "lockedEmployee": null } ], "score": { "initScore": 0, "hardScore": -60, "softScore": 0, "feasible": false, "solutionInitialized": true }, "constraintId": "defaultpkg/shiftRemainsUnassigned" } ], "score": { "initScore": 0, "hardScore": -60, "softScore": 0, "feasible": false, "solutionInitialized": true }, "constraintMatchCount": 1 }, "charge-1": { "justification": { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "spot": { "id": "reiskocher", "name": "Reiskocher", "requiredSkills": [ { "id": "reiskochen", "name": "Reis kochen" } ] }, "startDateTime": "2021-06-23T09:30", "endDateTime": "2021-06-23T10:30", "requiredSkills": null, "employee": null, "lockedEmployee": null }, "constraintMatchSet": [ { "constraintName": "shiftRemainsUnassigned", "justificationList": [ { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "spot": { "id": "reiskocher", "name": "Reiskocher", "requiredSkills": [ { "id": "reiskochen", "name": "Reis kochen" } ] }, "startDateTime": "2021-06-23T09:30", "endDateTime": "2021-06-23T10:30", "requiredSkills": null, "employee": null, "lockedEmployee": null } ], "score": { "initScore": 0, "hardScore": -60, "softScore": 0, "feasible": false, "solutionInitialized": true }, "constraintId": "defaultpkg/shiftRemainsUnassigned" } ], "score": { "initScore": 0, "hardScore": -60, "softScore": 0, "feasible": false, "solutionInitialized": true }, "constraintMatchCount": 1 }, "charge-2": { "justification": { "id": "3e1fec47-003e-4d9f-a0b7-4d49405b2dee", "spot": { "id": "lavabo", "name": "Lavabo", "requiredSkills": [ { "id": "reiswaschen", "name": "Reis waschen" } ] }, "startDateTime": "2021-06-23T08:30", "endDateTime": "2021-06-23T09:00", "requiredSkills": null, "employee": null, "lockedEmployee": null }, "constraintMatchSet": [ { "constraintName": "shiftRemainsUnassigned", "justificationList": [ { "id": "3e1fec47-003e-4d9f-a0b7-4d49405b2dee", "spot": { "id": "lavabo", "name": "Lavabo", "requiredSkills": [ { "id": "reiswaschen", "name": "Reis waschen" } ] }, "startDateTime": "2021-06-23T08:30", "endDateTime": "2021-06-23T09:00", "requiredSkills": null, "employee": null, "lockedEmployee": null } ], "score": { "initScore": 0, "hardScore": -30, "softScore": 0, "feasible": false, "solutionInitialized": true }, "constraintId": "defaultpkg/shiftRemainsUnassigned" } ], "score": { "initScore": 0, "hardScore": -30, "softScore": 0, "feasible": false, "solutionInitialized": true }, "constraintMatchCount": 1 } }, "type": "score_roster", "state": "complete", "id": 92, "tenant_id": 179, "uuid": "dfe1209d-7fad-4582-9e6c-b7551a037542", "created_at": "2021-12-02T15:56:25.310Z", "results": [ { "id": 107, "job_id": 92, "payload": { "meta": { "ruleFilePaths": [ "rosterConstraints.drl" ], "secondsSpentLimit": 10 }, "score": { "feasible": false, "hardScore": -150, "initScore": -3, "softScore": 0, "solutionInitialized": false }, "spots": [ { "id": "reiskocher", "name": "Reiskocher", "requiredSkills": [ "reiskochen" ] }, { "id": "lavabo", "name": "Lavabo", "requiredSkills": [ "reiswaschen" ] } ], "shifts": [ { "id": "82c397c6-8d58-4329-9c94-69d3637b6b1b", "spot": "reiskocher", "employee": null, "endDateTime": "2021-06-23T10:00", "startDateTime": "2021-06-23T09:00", "lockedEmployee": null, "requiredSkills": null }, { "id": "bb6ac335-e67b-43b3-8867-f8ef3e90bfb7", "spot": "reiskocher", "employee": null, "endDateTime": "2021-06-23T10:30", "startDateTime": "2021-06-23T09:30", "lockedEmployee": null, "requiredSkills": null }, { "id": "3e1fec47-003e-4d9f-a0b7-4d49405b2dee", "spot": "lavabo", "employee": null, "endDateTime": "2021-06-23T09:00", "startDateTime": "2021-06-23T08:30", "lockedEmployee": null, "requiredSkills": null } ], "skills": [ { "id": "reiskochen", "name": "Reis kochen" }, { "id": "reiswaschen", "name": "Reis waschen" } ], "contracts": [ { "id": "fulltime", "name": "Fulltime", "maximumMinutesPerDay": 0, "maximumMinutesPerWeek": 2550, "maximumMinutesPerYear": 0, "maximumMinutesPerMonth": 0 } ], "employees": [ { "id": "mrsrice", "name": "Mrs Rice", "skills": [ "reiskochen", "reiswaschen" ], "contract": "fulltime" }, { "id": "mrrice", "name": "Mr Rice", "skills": [ "reiswaschen" ], "contract": "fulltime" } ], "availabilities": [] }, "final": true, "created_at": "2021-12-02T15:56:28.035Z" } ] }
4.4. Get Job
(Polling)
echo $compute_roster | jq .job_id
http --ignore-stdin --pretty format \ $host/v1/tenants/$tenant_id/jobs/$job_id \ Authorization:"Token ${auth_token}"
{ "coercion": "spec", "in": [ "request", "path-params" ], "problems": [ { "in": [ "tenant_id" ], "path": [ "tenant_id" ], "pred": "clojure.core/int?", "val": "nil", "via": [ "spec$220/tenant_id" ] } ], "spec": "(spec-tools.core/spec {:spec (clojure.spec.alpha/keys :req-un [:spec$220/tenant_id :spec$220/uuid]), :type :map, :leaf? false})", "type": "reitit.coercion/request-coercion", "value": { "tenant_id": "nil", "uuid": "nil" } }
5. Appendix
5.1. Schema defintions
5.1.1. Roster
- Spec
(s/def ::secondsSpentLimit int?) (s/def ::ruleFilePath string?) (s/def ::ruleFilePaths (s/coll-of ::ruleFilePath)) (s/def ::meta (s/keys :req-un [::ruleFilePaths ::secondsSpentLimit])) (def ^:private id? (partial re-matches #"[\w-:.,;/]+")) (s/def ::id (s/and string? id?)) (s/def ::name string?) (s/def ::maximumMinutesPerDayPerEmployee int?) (s/def ::maximumMinutesPerWeekPerEmployee int?) (s/def ::maximumMinutesPerMonthPerEmployee int?) (s/def ::maximumMinutesPerYearPerEmployee int?) (s/def ::skill (s/keys :req-un [::id ::name] :opt-un [::maximumMinutesPerWeekPerEmployee])) (s/def ::skills (s/coll-of ::skill)) (s/def ::requiredSkills (s/coll-of ::id)) (s/def ::maximumMinutes int?) (s/def ::spot (s/keys :req-un [::id ::name] :opt-un [::requiredSkills ::maximumMinutes])) (s/def ::spots (s/coll-of ::spot)) (s/def ::maximumMinutesPerDay int?) (s/def ::maximumMinutesPerWeek int?) (s/def ::maximumMinutesPerMonth int?) (s/def ::maximumMinutesPerYear int?) (s/def ::contract (s/keys :req-un [::id ::name] :opt-un [::maximumMinutesPerDay ::maximumMinutesPerWeek ::maximumMinutesPerMonth ::maximumMinutesPerYear])) (s/def ::contracts (s/coll-of ::contract)) (s/def :id/contract ::id) (s/def :id/skills (s/coll-of ::id)) (s/def ::employee (s/keys :req-un [::id ::name :id/contract :id/skills])) (s/def ::employees (s/coll-of ::employee)) (s/def :id/spot ::id) (s/def :id/employee ::id) (s/def ::lockedEmployee ::id) (s/def ::shift (s/or :optimize-for-employee (s/keys :req-un [::id :id/spot ::startDateTime ::endDateTime] :opt-un [::requiredSkills :id/employee ::lockedEmployee]) :optimize-for-spot (s/keys :req-un [::id :id/employee ::startDateTime ::endDateTime] :opt-un [::requiredSkills :id/spot ::lockedEmployee]))) (s/def ::shifts (s/coll-of ::shift)) (def ^:private datetime? (partial re-matches #"\d{4}-\d\d-\d\dT\d\d:\d\d")) (s/def ::startDateTime (s/and string? datetime?)) (s/def ::endDateTime (s/and string? datetime?)) (s/def ::state #{"UNAVAILABLE" "UNDESIRED" "DESIRED"}) (s/def ::availability (s/keys :req-un [::startDateTime ::endDateTime ::state :id/employee])) (s/def ::availabilities (s/coll-of ::availability)) (s/def ::initScore int?) (s/def ::hardScore int?) (s/def ::softScore int?) (s/def ::feasible boolean?) (s/def ::solutionInitialized boolean?) (s/def ::score (s/keys :req-un [::initScore ::hardScore ::softScore ::feasible ::solutionInitialized])) (s/def ::roster (s/keys :req-un [::skills ::spots ::contracts ::employees ::shifts] :opt-un [::meta ::availabilities ::score]))
- Swagger
{ "type": "object", "properties": { "skills": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] }, "name": { "type": "string" } }, "required": [ "id", "name" ], "title": "spapi.roster/skill" } }, "spots": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] }, "name": { "type": "string" }, "requiredSkills": { "type": "array", "items": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] } } }, "required": [ "id", "name" ], "title": "spapi.roster/spot" } }, "contracts": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] }, "name": { "type": "string" }, "maximumMinutesPerDay": { "type": "integer", "format": "int64" }, "maximumMinutesPerWeek": { "type": "integer", "format": "int64" }, "maximumMinutesPerMonth": { "type": "integer", "format": "int64" }, "maximumMinutesPerYear": { "type": "integer", "format": "int64" } }, "required": [ "id", "name" ], "title": "spapi.roster/contract" } }, "employees": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] }, "name": { "type": "string" }, "contract": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] }, "skills": { "type": "array", "items": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] } } }, "required": [ "id", "name", "contract", "skills" ], "title": "spapi.roster/employee" } }, "shifts": { "type": "array", "items": { "type": "object", "properties": { "spot": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] }, "startDateTime": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] }, "endDateTime": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] }, "requiredSkills": { "type": "array", "items": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] } }, "employee": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] }, "lockedEmployee": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] } }, "required": [ "spot", "startDateTime", "endDateTime" ], "title": "spapi.roster/shift" } }, "meta": { "type": "object", "properties": { "ruleFilePaths": { "type": "array", "items": { "type": "string" } }, "secondsSpentLimit": { "type": "integer", "format": "int64" } }, "required": [ "ruleFilePaths", "secondsSpentLimit" ], "title": "spapi.roster/meta" }, "availabilities": { "type": "array", "items": { "type": "object", "properties": { "startDateTime": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] }, "endDateTime": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] }, "state": { "enum": [ "DESIRED", "UNAVAILABLE", "UNDESIRED" ], "type": "string" }, "employee": { "type": "string", "x-allOf": [ { "type": "string" }, {} ] } }, "required": [ "startDateTime", "endDateTime", "state", "employee" ], "title": "spapi.roster/availability" } }, "score": { "type": "object", "properties": { "initScore": { "type": "integer", "format": "int64" }, "hardScore": { "type": "integer", "format": "int64" }, "softScore": { "type": "integer", "format": "int64" }, "feasible": { "type": "boolean" }, "solutionInitialized": { "type": "boolean" } }, "required": [ "initScore", "hardScore", "softScore", "feasible", "solutionInitialized" ], "title": "spapi.roster/score" } }, "required": [ "skills", "spots", "contracts", "employees", "shifts" ], "title": "spapi.roster/roster" }