Compare commits
44
Commits
23529ba441
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d577b2cdf8 | ||
|
|
0874f05126 | ||
|
|
6ca3db86cb | ||
|
|
96e6a0d625 | ||
|
|
fc2413538c | ||
|
|
5ff78faa07 | ||
|
|
d1fbbe2dc9 | ||
|
|
b5525bd3fb | ||
|
|
33f50c7278 | ||
|
|
981cc314f0 | ||
|
|
0861e371e9 | ||
|
|
13c5ae598a | ||
|
|
0f31dfe1a9 | ||
|
|
02598cb665 | ||
|
|
09c8124cdd | ||
|
|
2949327d39 | ||
|
|
ea68885ff3 | ||
|
|
e288585cd3 | ||
|
|
4cad1158c6 | ||
|
|
f90e6895b4 | ||
|
|
827753644f | ||
|
|
935b4baaba | ||
|
|
dee80ed4fa | ||
|
|
a071b1b027 | ||
|
|
ee263aade1 | ||
|
|
68b80ace23 | ||
|
|
4bb4f5b97a | ||
|
|
d741d7e23c | ||
|
|
64c4189d5f | ||
|
|
277f14747a | ||
|
|
e637c5ac60 | ||
|
|
c71ae4285a | ||
|
|
c248e67304 | ||
|
|
628114ad91 | ||
|
|
f8c6e38841 | ||
|
|
ba268f9b48 | ||
|
|
e7f27a8ee6 | ||
|
|
1d7202bf04 | ||
|
|
82868f8c22 | ||
|
|
d66ddf29ad | ||
|
|
e6bdd4a09e | ||
|
|
62e48ef205 | ||
|
|
b98c28401f | ||
|
|
94b921192f |
@@ -16,10 +16,13 @@ try:
|
|||||||
WorkRequests,
|
WorkRequests,
|
||||||
HardDayDependency,
|
HardDayDependency,
|
||||||
HardDayExclusion,
|
HardDayExclusion,
|
||||||
|
ShiftStartDate,
|
||||||
|
ShiftEndDate,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
NonWorkingDays = OutOfProgramme = NotAvailableToWork = PreferenceNotToWork = None
|
NonWorkingDays = OutOfProgramme = NotAvailableToWork = PreferenceNotToWork = None
|
||||||
WorkRequests = HardDayDependency = HardDayExclusion = None
|
WorkRequests = HardDayDependency = HardDayExclusion = None
|
||||||
|
ShiftStartDate = ShiftEndDate = None
|
||||||
|
|
||||||
|
|
||||||
class WorkerForm(forms.ModelForm):
|
class WorkerForm(forms.ModelForm):
|
||||||
@@ -45,6 +48,10 @@ class WorkerForm(forms.ModelForm):
|
|||||||
# Complex structured options edited as JSON
|
# Complex structured options edited as JSON
|
||||||
assign_as_block_preferences = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->weight", label="Block preferences (JSON)")
|
assign_as_block_preferences = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->weight", label="Block preferences (JSON)")
|
||||||
shift_fte_overrides = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->fte", label="Shift FTE overrides (JSON)")
|
shift_fte_overrides = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->fte", label="Shift FTE overrides (JSON)")
|
||||||
|
exact_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->exact_count", label="Exact shifts (JSON)")
|
||||||
|
shift_start_dates = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->start_date (YYYY-MM-DD)", label="Shift start dates (JSON)")
|
||||||
|
shift_end_dates = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->end_date (YYYY-MM-DD)", label="Shift end dates (JSON)")
|
||||||
|
groups = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of group names (e.g. [\"group1\", \"group2\"])", label="Groups (JSON)")
|
||||||
previous_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="Free JSON structure for previous shifts", label="Previous shifts (JSON)")
|
previous_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="Free JSON structure for previous shifts", label="Previous shifts (JSON)")
|
||||||
shift_balance_extra = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON structure for extra shift-balance penalties", label="Shift balance extra (JSON)")
|
shift_balance_extra = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON structure for extra shift-balance penalties", label="Shift balance extra (JSON)")
|
||||||
allowed_multi_shift_sets = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of sets (e.g. [[\"a\",\"b\"], ...])", label="Allowed multi-shift sets (JSON)")
|
allowed_multi_shift_sets = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of sets (e.g. [[\"a\",\"b\"], ...])", label="Allowed multi-shift sets (JSON)")
|
||||||
@@ -94,6 +101,10 @@ class WorkerForm(forms.ModelForm):
|
|||||||
for json_fld in [
|
for json_fld in [
|
||||||
"assign_as_block_preferences",
|
"assign_as_block_preferences",
|
||||||
"shift_fte_overrides",
|
"shift_fte_overrides",
|
||||||
|
"exact_shifts",
|
||||||
|
"shift_start_dates",
|
||||||
|
"shift_end_dates",
|
||||||
|
"groups",
|
||||||
"previous_shifts",
|
"previous_shifts",
|
||||||
"shift_balance_extra",
|
"shift_balance_extra",
|
||||||
"allowed_multi_shift_sets",
|
"allowed_multi_shift_sets",
|
||||||
@@ -146,6 +157,10 @@ class WorkerForm(forms.ModelForm):
|
|||||||
json_fields = [
|
json_fields = [
|
||||||
"assign_as_block_preferences",
|
"assign_as_block_preferences",
|
||||||
"shift_fte_overrides",
|
"shift_fte_overrides",
|
||||||
|
"exact_shifts",
|
||||||
|
"shift_start_dates",
|
||||||
|
"shift_end_dates",
|
||||||
|
"groups",
|
||||||
"previous_shifts",
|
"previous_shifts",
|
||||||
"shift_balance_extra",
|
"shift_balance_extra",
|
||||||
"allowed_multi_shift_sets",
|
"allowed_multi_shift_sets",
|
||||||
@@ -266,9 +281,10 @@ class WorkerForm(forms.ModelForm):
|
|||||||
cleaned["work_requests"] = _parse_and_validate("work_requests", WorkRequests)
|
cleaned["work_requests"] = _parse_and_validate("work_requests", WorkRequests)
|
||||||
cleaned["locum_availability"] = _parse_and_validate("locum_availability", WorkRequests)
|
cleaned["locum_availability"] = _parse_and_validate("locum_availability", WorkRequests)
|
||||||
|
|
||||||
# Hard day dependency/exclusion structures
|
|
||||||
cleaned["hard_day_dependencies"] = _parse_and_validate("hard_day_dependencies", HardDayDependency)
|
cleaned["hard_day_dependencies"] = _parse_and_validate("hard_day_dependencies", HardDayDependency)
|
||||||
cleaned["hard_day_exclusions"] = _parse_and_validate("hard_day_exclusions", HardDayExclusion)
|
cleaned["hard_day_exclusions"] = _parse_and_validate("hard_day_exclusions", HardDayExclusion)
|
||||||
|
cleaned["shift_start_dates"] = _parse_and_validate("shift_start_dates", ShiftStartDate)
|
||||||
|
cleaned["shift_end_dates"] = _parse_and_validate("shift_end_dates", ShiftEndDate)
|
||||||
|
|
||||||
# forced assignments: expect list of tuples [week, day, shift]
|
# forced assignments: expect list of tuples [week, day, shift]
|
||||||
fa = cleaned.get("forced_assignments")
|
fa = cleaned.get("forced_assignments")
|
||||||
@@ -397,8 +413,8 @@ class RotaScheduleForm(forms.ModelForm):
|
|||||||
# legacy builder args (the remaining keys in `options` are treated as
|
# legacy builder args (the remaining keys in `options` are treated as
|
||||||
# constraint configuration for `RotaConstraintOptions`).
|
# constraint configuration for `RotaConstraintOptions`).
|
||||||
builder_args = {
|
builder_args = {
|
||||||
"balance_offset_modifier": {"type": "int", "default": 1, "help": "Balance offset modifier used by the builder"},
|
"balance_offset_modifier": {"type": "float", "default": 1.0, "help": "Balance offset modifier used by the builder"},
|
||||||
"ltft_balance_offset": {"type": "int", "default": 1, "help": "LTFT balance offset used by the builder"},
|
"ltft_balance_offset": {"type": "float", "default": 1.0, "help": "LTFT balance offset used by the builder"},
|
||||||
"use_previous_shifts": {"type": "bool", "default": False, "help": "Use previous shifts when building the rota"},
|
"use_previous_shifts": {"type": "bool", "default": False, "help": "Use previous shifts when building the rota"},
|
||||||
"use_shift_balance_extra": {"type": "bool", "default": False, "help": "Enable extra shift-balance penalties"},
|
"use_shift_balance_extra": {"type": "bool", "default": False, "help": "Enable extra shift-balance penalties"},
|
||||||
"use_bank_holiday_extra": {"type": "bool", "default": False, "help": "Apply bank-holiday balancing extras"},
|
"use_bank_holiday_extra": {"type": "bool", "default": False, "help": "Apply bank-holiday balancing extras"},
|
||||||
@@ -412,6 +428,8 @@ class RotaScheduleForm(forms.ModelForm):
|
|||||||
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=k.replace("_", " "), help_text=meta.get("help"))
|
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=k.replace("_", " "), help_text=meta.get("help"))
|
||||||
elif meta["type"] == "int":
|
elif meta["type"] == "int":
|
||||||
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||||
|
elif meta["type"] == "float":
|
||||||
|
self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||||
else:
|
else:
|
||||||
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||||
|
|
||||||
@@ -795,6 +813,16 @@ class ShiftForm(forms.Form):
|
|||||||
widget=forms.Textarea(attrs={"rows": 4, "class": "textarea"}),
|
widget=forms.Textarea(attrs={"rows": 4, "class": "textarea"}),
|
||||||
help_text="Optional JSON list of constraint objects for this shift (e.g. [{'name':'night','options':{}}])",
|
help_text="Optional JSON list of constraint objects for this shift (e.g. [{'name':'night','options':{}}])",
|
||||||
)
|
)
|
||||||
|
include_groups = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
help_text="Comma separated list of groups that can work this shift (leave empty for all)",
|
||||||
|
label="Include groups",
|
||||||
|
)
|
||||||
|
exclude_groups = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
help_text="Comma separated list of groups that cannot work this shift",
|
||||||
|
label="Exclude groups",
|
||||||
|
)
|
||||||
|
|
||||||
def clean_sites(self):
|
def clean_sites(self):
|
||||||
val = self.cleaned_data["sites"]
|
val = self.cleaned_data["sites"]
|
||||||
@@ -836,3 +864,22 @@ class ShiftForm(forms.Form):
|
|||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise forms.ValidationError(f"Invalid JSON for constraints: {exc}")
|
raise forms.ValidationError(f"Invalid JSON for constraints: {exc}")
|
||||||
|
|
||||||
|
def clean_include_groups(self):
|
||||||
|
val = self.cleaned_data.get("include_groups") or ""
|
||||||
|
return [g.strip() for g in val.split(",") if g.strip()]
|
||||||
|
|
||||||
|
def clean_exclude_groups(self):
|
||||||
|
val = self.cleaned_data.get("exclude_groups") or ""
|
||||||
|
return [g.strip() for g in val.split(",") if g.strip()]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
include = cleaned_data.get("include_groups") or []
|
||||||
|
exclude = cleaned_data.get("exclude_groups") or []
|
||||||
|
overlap = set(include).intersection(set(exclude))
|
||||||
|
if overlap:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
f"Include groups and Exclude groups cannot overlap. Overlapping groups: {', '.join(overlap)}"
|
||||||
|
)
|
||||||
|
return cleaned_data
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ class Worker(models.Model):
|
|||||||
"max_shifts_per_week_by_shift_name",
|
"max_shifts_per_week_by_shift_name",
|
||||||
"assign_as_block_preferences",
|
"assign_as_block_preferences",
|
||||||
"shift_fte_overrides",
|
"shift_fte_overrides",
|
||||||
|
"exact_shifts",
|
||||||
]
|
]
|
||||||
list_keys = [
|
list_keys = [
|
||||||
"oop",
|
"oop",
|
||||||
@@ -241,6 +242,8 @@ class Worker(models.Model):
|
|||||||
"hard_day_exclusions",
|
"hard_day_exclusions",
|
||||||
"forced_assignments",
|
"forced_assignments",
|
||||||
"forced_assignments_by_date",
|
"forced_assignments_by_date",
|
||||||
|
"shift_start_dates",
|
||||||
|
"shift_end_dates",
|
||||||
]
|
]
|
||||||
|
|
||||||
for k in int_keys:
|
for k in int_keys:
|
||||||
@@ -273,6 +276,17 @@ class Worker(models.Model):
|
|||||||
else:
|
else:
|
||||||
pyd_kwargs[k] = []
|
pyd_kwargs[k] = []
|
||||||
|
|
||||||
|
set_keys = ["groups"]
|
||||||
|
for k in set_keys:
|
||||||
|
v = pyd_kwargs.get(k, None)
|
||||||
|
try:
|
||||||
|
if v is None:
|
||||||
|
pyd_kwargs[k] = set()
|
||||||
|
else:
|
||||||
|
pyd_kwargs[k] = set(v)
|
||||||
|
except Exception:
|
||||||
|
pyd_kwargs[k] = set()
|
||||||
|
|
||||||
# allowed_multi_shift_sets is expected to be a list of sets; if the
|
# allowed_multi_shift_sets is expected to be a list of sets; if the
|
||||||
# DB contains lists-of-lists convert inner lists to sets.
|
# DB contains lists-of-lists convert inner lists to sets.
|
||||||
ams = pyd_kwargs.get("allowed_multi_shift_sets")
|
ams = pyd_kwargs.get("allowed_multi_shift_sets")
|
||||||
|
|||||||
@@ -1690,6 +1690,8 @@ def shift_add(request, rota_id):
|
|||||||
"workers_required": int(data["workers_required"]),
|
"workers_required": int(data["workers_required"]),
|
||||||
"assign_as_block": bool(data["assign_as_block"]),
|
"assign_as_block": bool(data["assign_as_block"]),
|
||||||
"balance_offset": data.get("balance_offset"),
|
"balance_offset": data.get("balance_offset"),
|
||||||
|
"include_groups": data.get("include_groups", []),
|
||||||
|
"exclude_groups": data.get("exclude_groups", []),
|
||||||
"constraints": data.get("constraints", []),
|
"constraints": data.get("constraints", []),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1738,6 +1740,9 @@ def shift_edit(request, rota_id, idx):
|
|||||||
"days": data["days"],
|
"days": data["days"],
|
||||||
"workers_required": int(data["workers_required"]),
|
"workers_required": int(data["workers_required"]),
|
||||||
"assign_as_block": bool(data["assign_as_block"]),
|
"assign_as_block": bool(data["assign_as_block"]),
|
||||||
|
"balance_offset": data.get("balance_offset"),
|
||||||
|
"include_groups": data.get("include_groups", []),
|
||||||
|
"exclude_groups": data.get("exclude_groups", []),
|
||||||
"constraints": data.get("constraints", []),
|
"constraints": data.get("constraints", []),
|
||||||
}
|
}
|
||||||
current = rota.shifts or []
|
current = rota.shifts or []
|
||||||
@@ -1766,6 +1771,8 @@ def shift_edit(request, rota_id, idx):
|
|||||||
"workers_required": shift.get("workers_required"),
|
"workers_required": shift.get("workers_required"),
|
||||||
"assign_as_block": shift.get("assign_as_block", False),
|
"assign_as_block": shift.get("assign_as_block", False),
|
||||||
"balance_offset": shift.get("balance_offset", None),
|
"balance_offset": shift.get("balance_offset", None),
|
||||||
|
"include_groups": ",".join(shift.get("include_groups") or []),
|
||||||
|
"exclude_groups": ",".join(shift.get("exclude_groups") or []),
|
||||||
"constraints": json.dumps(shift.get("constraints", [])) if shift.get("constraints") is not None else "",
|
"constraints": json.dumps(shift.get("constraints", [])) if shift.get("constraints") is not None else "",
|
||||||
}
|
}
|
||||||
form = ShiftForm(initial=initial)
|
form = ShiftForm(initial=initial)
|
||||||
|
|||||||
+264
-188
@@ -2,9 +2,10 @@ from pyomo.opt.results.container import ignore
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from rota_generator.shifts import MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, WorkerRequirement, days, RotaConstraintOptions
|
from rota_generator.shifts import MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, WorkerRequirement, days, RotaConstraintOptions, MaxShiftsPerWeekBlockConstraint
|
||||||
import typer
|
import typer
|
||||||
import subprocess
|
import subprocess
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -15,23 +16,168 @@ from rota_generator.workers import (
|
|||||||
NonWorkingDays,
|
NonWorkingDays,
|
||||||
WorkRequests,
|
WorkRequests,
|
||||||
PreferenceNotToWork,
|
PreferenceNotToWork,
|
||||||
OutOfProgramme,
|
OutOfProgramme, MaxUniqueShiftsPerWeekBlockConstraint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
app = typer.Typer()
|
app = typer.Typer()
|
||||||
|
|
||||||
|
ROTA_REQUESTS = "/home/ross/Sync/cons/requests.ods"
|
||||||
|
ROTA_B_PATH = "/home/ross/Sync/cons/rota_b.xlsx"
|
||||||
|
ROTA_START_DATE = "2026-08-03"
|
||||||
|
|
||||||
|
|
||||||
sites = ("rota a", "rota b", "rota c")
|
sites = ("rota a", "rota b", "rota c")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_worker_key(value):
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return re.sub(r"\s+", " ", text).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_worker_alignment(base_workers, other_workers, base_label, other_label):
|
||||||
|
"""Validate that two worker collections contain the same members.
|
||||||
|
|
||||||
|
Comparison is case-insensitive and whitespace-normalized.
|
||||||
|
Raises ValueError with a clear diff when the sets do not match.
|
||||||
|
"""
|
||||||
|
base_clean = [str(w).strip() for w in base_workers if str(w).strip()]
|
||||||
|
other_clean = [str(w).strip() for w in other_workers if str(w).strip()]
|
||||||
|
|
||||||
|
base_norm_to_raw = {_normalize_worker_key(w): w for w in base_clean}
|
||||||
|
other_norm_to_raw = {_normalize_worker_key(w): w for w in other_clean}
|
||||||
|
|
||||||
|
base_norm = set(base_norm_to_raw.keys())
|
||||||
|
other_norm = set(other_norm_to_raw.keys())
|
||||||
|
|
||||||
|
only_in_base_norm = sorted(base_norm - other_norm)
|
||||||
|
only_in_other_norm = sorted(other_norm - base_norm)
|
||||||
|
|
||||||
|
if not only_in_base_norm and not only_in_other_norm:
|
||||||
|
return
|
||||||
|
|
||||||
|
only_in_base = [base_norm_to_raw[k] for k in only_in_base_norm]
|
||||||
|
only_in_other = [other_norm_to_raw[k] for k in only_in_other_norm]
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
"Worker mismatch detected between sources. "
|
||||||
|
f"{base_label} only: {only_in_base}. "
|
||||||
|
f"{other_label} only: {only_in_other}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sheet_date(value):
|
||||||
|
if pd.isna(value):
|
||||||
|
return None
|
||||||
|
parsed = pd.to_datetime(value, errors="coerce")
|
||||||
|
if pd.isna(parsed):
|
||||||
|
return None
|
||||||
|
return parsed.date()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_worked_target_cell(value):
|
||||||
|
"""Parse values like '5 (6.88)' into (worked, target)."""
|
||||||
|
if pd.isna(value):
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text or text.lower() in {"nan", "-"}:
|
||||||
|
return None
|
||||||
|
|
||||||
|
match = re.match(r"^\s*(-?\d+(?:\.\d+)?)\s*\(([^)]+)\)\s*$", text)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
worked = float(match.group(1).strip())
|
||||||
|
target = float(match.group(2).strip())
|
||||||
|
return worked, target
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fallback for cells that contain only one number.
|
||||||
|
try:
|
||||||
|
target = float(text)
|
||||||
|
return 0.0, target
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_priors_from_running_totals(running_totals_df):
|
||||||
|
"""Extract previous shifts map from Running totals tab.
|
||||||
|
|
||||||
|
Reads only oncall nights, twilights and weekend A values.
|
||||||
|
"""
|
||||||
|
priors_map = {}
|
||||||
|
if running_totals_df is None or running_totals_df.empty:
|
||||||
|
return priors_map
|
||||||
|
|
||||||
|
# Find the header row that starts with 'Worker'.
|
||||||
|
header_row_index = None
|
||||||
|
for i in range(len(running_totals_df)):
|
||||||
|
first_cell = str(running_totals_df.iloc[i, 0]).strip().lower()
|
||||||
|
if first_cell == "worker":
|
||||||
|
header_row_index = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_row_index is None:
|
||||||
|
return priors_map
|
||||||
|
|
||||||
|
header_values = [
|
||||||
|
str(c).strip().lower() if not pd.isna(c) else ""
|
||||||
|
for c in running_totals_df.iloc[header_row_index].tolist()
|
||||||
|
]
|
||||||
|
|
||||||
|
def _find_col(header_name):
|
||||||
|
for idx, col in enumerate(header_values):
|
||||||
|
if col == header_name:
|
||||||
|
return idx
|
||||||
|
return None
|
||||||
|
|
||||||
|
worker_col = _find_col("worker")
|
||||||
|
oncall_col = _find_col("oncall night")
|
||||||
|
twilight_col = _find_col("twilight")
|
||||||
|
weekend_a_col = _find_col("weekend a")
|
||||||
|
|
||||||
|
if worker_col is None:
|
||||||
|
return priors_map
|
||||||
|
|
||||||
|
for i in range(header_row_index + 1, len(running_totals_df)):
|
||||||
|
row = running_totals_df.iloc[i]
|
||||||
|
worker_raw = row.iloc[worker_col] if worker_col < len(row) else None
|
||||||
|
worker = str(worker_raw).strip() if not pd.isna(worker_raw) else ""
|
||||||
|
if not worker or worker.lower() in {"nan", "total"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
prev = {}
|
||||||
|
if oncall_col is not None and oncall_col < len(row):
|
||||||
|
parsed = _parse_worked_target_cell(row.iloc[oncall_col])
|
||||||
|
if parsed is not None:
|
||||||
|
prev["oncall"] = parsed
|
||||||
|
|
||||||
|
if twilight_col is not None and twilight_col < len(row):
|
||||||
|
parsed = _parse_worked_target_cell(row.iloc[twilight_col])
|
||||||
|
if parsed is not None:
|
||||||
|
prev["twilight"] = parsed
|
||||||
|
|
||||||
|
if weekend_a_col is not None and weekend_a_col < len(row):
|
||||||
|
parsed = _parse_worked_target_cell(row.iloc[weekend_a_col])
|
||||||
|
if parsed is not None:
|
||||||
|
prev["weekend"] = parsed
|
||||||
|
|
||||||
|
if prev:
|
||||||
|
priors_map[worker] = prev
|
||||||
|
|
||||||
|
return priors_map
|
||||||
|
|
||||||
def extract_leave_and_rota_from_calender(calender_df):
|
def extract_leave_and_rota_from_calender(calender_df):
|
||||||
"""
|
"""
|
||||||
Extract leave and rota assignments from calender_df.
|
Extract leave and rota assignments from calender_df.
|
||||||
- The first row contains worker names, starting from the 5th column (index 4).
|
- The first row contains worker names, starting from the 5th column (index 4).
|
||||||
- The second column (index 1) contains dates in dd/mm/yyyy format.
|
- The second column (index 1) contains dates in dd/mm/yyyy format.
|
||||||
- The second column (index 1) also contains the rota/leave code for that date.
|
- The second column (index 1) also contains the rota/leave code for that date.
|
||||||
Returns a list of dicts: {"date": ..., "worker": ..., "rota": ...}
|
Returns a list of dicts: {"date": ..., "/worker": ..., "rota": ...}
|
||||||
"""
|
"""
|
||||||
# Get worker names from the first row, starting from column 5 (index 4)
|
# Get worker names from the first row, starting from column 5 (index 4)
|
||||||
worker_names = [str(x).strip() for x in calender_df.columns[5:]]
|
worker_names = [str(x).strip() for x in calender_df.columns[5:]]
|
||||||
@@ -49,15 +195,11 @@ def extract_leave_and_rota_from_calender(calender_df):
|
|||||||
work_requests = []
|
work_requests = []
|
||||||
# Process leave data from row 3 onwards (index 2 and up)
|
# Process leave data from row 3 onwards (index 2 and up)
|
||||||
for idx, row in calender_df.iloc[3:].iterrows():
|
for idx, row in calender_df.iloc[3:].iterrows():
|
||||||
date_str = str(row.iloc[1]).strip().split(" ")[0] # Get the date string from the second column (index 1)
|
date = _parse_sheet_date(row.iloc[1])
|
||||||
if not date_str or date_str.lower() == "nan":
|
if date is None:
|
||||||
continue
|
continue
|
||||||
try:
|
|
||||||
date = datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
|
|
||||||
except Exception:
|
|
||||||
continue # skip rows with invalid date
|
|
||||||
|
|
||||||
if date < datetime.date(2026, 2, 16):
|
if date < datetime.date.fromisoformat(ROTA_START_DATE):
|
||||||
continue # skip rows before rota start date
|
continue # skip rows before rota start date
|
||||||
for i, worker in enumerate(worker_names):
|
for i, worker in enumerate(worker_names):
|
||||||
if not worker or worker.lower() == "nan":
|
if not worker or worker.lower() == "nan":
|
||||||
@@ -81,7 +223,7 @@ def extract_leave_and_rota_from_calender(calender_df):
|
|||||||
|
|
||||||
|
|
||||||
#print(leave_requests)
|
#print(leave_requests)
|
||||||
return leave_requests, work_requests#, rota_data
|
return leave_requests, work_requests, worker_names
|
||||||
|
|
||||||
def extract_shift_assignments_from_rota(rota_df):
|
def extract_shift_assignments_from_rota(rota_df):
|
||||||
"""
|
"""
|
||||||
@@ -96,29 +238,30 @@ def extract_shift_assignments_from_rota(rota_df):
|
|||||||
assignments = []
|
assignments = []
|
||||||
|
|
||||||
skipped_rows = 0
|
skipped_rows = 0
|
||||||
|
rota_start = datetime.date.fromisoformat(ROTA_START_DATE)
|
||||||
for idx, row in rota_df.iloc[2:].iterrows():
|
for idx, row in rota_df.iloc[2:].iterrows():
|
||||||
week_start_str = str(row.iloc[0]).strip().split(" ")[0] # Get the week start date from the first column (index 0)
|
week_start = _parse_sheet_date(row.iloc[0])
|
||||||
try:
|
if week_start is None:
|
||||||
week_start = datetime.datetime.strptime(week_start_str, "%Y-%m-%d").date()
|
print(f"Invalid date format in row {idx}: {row.iloc[0]}")
|
||||||
except Exception:
|
|
||||||
print(f"Invalid date format in row {idx}: {week_start_str}")
|
|
||||||
continue # skip rows with invalid date
|
continue # skip rows with invalid date
|
||||||
|
|
||||||
if week_start < datetime.date(2026, 2, 16):
|
if week_start < rota_start:
|
||||||
skipped_rows += 1
|
skipped_rows += 1
|
||||||
continue # skip rows before rota start date
|
continue # skip rows before rota start date
|
||||||
|
|
||||||
|
week = ((week_start - rota_start).days // 7) + 1
|
||||||
|
|
||||||
for i, day in enumerate(days):
|
for i, day in enumerate(days):
|
||||||
for j, shift in enumerate(shifts):
|
for j, shift in enumerate(shifts):
|
||||||
if i < 5:
|
if i < 5:
|
||||||
# Mon-Fri: two columns per day (twilight, oncall)
|
# Mon-Fri: two columns per day (twilight, oncall)
|
||||||
col_idx = 1 + i * 2 + j # 1 for Mon twilight, 2 for Mon oncall, etc.
|
col_idx = 2 + i * 2 + j # 2 for Mon twilight, 3 for Mon oncall, etc.
|
||||||
weekend = False
|
weekend = False
|
||||||
else:
|
else:
|
||||||
# Sat/Sun: only one column for "weekend" shift (twilight), skip "night"
|
# Sat/Sun: only one column for "weekend" shift (twilight), skip "night"
|
||||||
weekend = True
|
weekend = True
|
||||||
if j == 0:
|
if j == 0:
|
||||||
col_idx = 11 + (i - 5) # 11 for Sat, 12 for Sun
|
col_idx = 12 + (i - 5) # 12 for Sat, 13 for Sun
|
||||||
else:
|
else:
|
||||||
continue # No night shift column for Sat/Sun
|
continue # No night shift column for Sat/Sun
|
||||||
if col_idx >= len(row):
|
if col_idx >= len(row):
|
||||||
@@ -131,7 +274,7 @@ def extract_shift_assignments_from_rota(rota_df):
|
|||||||
shift = "weekend"
|
shift = "weekend"
|
||||||
assignments.append({
|
assignments.append({
|
||||||
"week_start": week_start,
|
"week_start": week_start,
|
||||||
"week": idx-1-skipped_rows, # 1-indexed week number
|
"week": week,
|
||||||
"day": day,
|
"day": day,
|
||||||
"date": week_start + datetime.timedelta(days=i),
|
"date": week_start + datetime.timedelta(days=i),
|
||||||
"shift": shift,
|
"shift": shift,
|
||||||
@@ -149,7 +292,7 @@ def extract_shift_assignments_from_rota(rota_df):
|
|||||||
|
|
||||||
def load_workers():
|
def load_workers():
|
||||||
# Path to the ODS file
|
# Path to the ODS file
|
||||||
ods_path = "cons/CONSULTANT TWILIGHT & ONCALL ROTA.ods"
|
ods_path = ROTA_REQUESTS
|
||||||
|
|
||||||
# Read all sheets
|
# Read all sheets
|
||||||
xls = pd.read_excel(ods_path, engine="odf", sheet_name=None)
|
xls = pd.read_excel(ods_path, engine="odf", sheet_name=None)
|
||||||
@@ -158,15 +301,22 @@ def load_workers():
|
|||||||
days_df = xls["Days"]
|
days_df = xls["Days"]
|
||||||
calender_df = xls["Calendar"]
|
calender_df = xls["Calendar"]
|
||||||
rota_df = xls["Rota"]
|
rota_df = xls["Rota"]
|
||||||
|
running_totals_df = xls.get("Running totals")
|
||||||
|
|
||||||
|
|
||||||
rota_b_path = "cons/Rota B Feb -July 2026.xlsx"
|
rota_b_path = ROTA_B_PATH
|
||||||
rota_b_df = pd.read_excel(rota_b_path, engine="openpyxl", sheet_name="Sheet1", header=None)
|
rota_b_df = pd.read_excel(rota_b_path, engine="openpyxl", sheet_name="Sheet1", header=None)
|
||||||
# Extract rota_b assignments: date in column A, worker in column B
|
# Extract rota_b assignments: date in column A, worker in column B
|
||||||
rota_b_assignments = defaultdict(list)
|
rota_b_assignments = defaultdict(list)
|
||||||
for idx, row in rota_b_df.iterrows():
|
for idx, row in rota_b_df.iterrows():
|
||||||
|
logger.debug(f"{idx}: {row}")
|
||||||
date_val = row.iloc[0]
|
date_val = row.iloc[0]
|
||||||
worker_val = row.iloc[1].upper() if len(row) > 1 else None
|
try:
|
||||||
|
worker_val = row.iloc[1].upper() if len(row) > 1 else None
|
||||||
|
except AttributeError:
|
||||||
|
# end of file
|
||||||
|
break
|
||||||
|
|
||||||
if pd.isna(date_val) or pd.isna(worker_val):
|
if pd.isna(date_val) or pd.isna(worker_val):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -180,53 +330,9 @@ def load_workers():
|
|||||||
print(rota_b_assignments)
|
print(rota_b_assignments)
|
||||||
# You can now use rota_b_assignments as needed
|
# You can now use rota_b_assignments as needed
|
||||||
|
|
||||||
# --- Load prior allocations (targets) from CSV ---
|
# --- Load prior allocations from requests.ods -> Running totals tab ---
|
||||||
priors_path = "cons/sep2025priors.csv"
|
priors_map = _load_priors_from_running_totals(running_totals_df)
|
||||||
priors_map = {}
|
logger.debug("priors_map from Running totals: {}", priors_map)
|
||||||
try:
|
|
||||||
priors_df = pd.read_csv(priors_path)
|
|
||||||
for _, prow in priors_df.iterrows():
|
|
||||||
initial = str(prow.get("Worker", "")).strip()
|
|
||||||
if not initial or initial.lower() == "nan":
|
|
||||||
continue
|
|
||||||
prev = {}
|
|
||||||
# expected columns: oncall, twilight, weekend
|
|
||||||
for col in ("oncall", "twilight", "weekend"):
|
|
||||||
raw = prow.get(col, "")
|
|
||||||
if pd.isna(raw) or raw == "" or str(raw).strip() in ("-", "nan"):
|
|
||||||
continue
|
|
||||||
s = str(raw).strip()
|
|
||||||
# Expect formats like: '4 (2.50)' or '3 (2.86)'
|
|
||||||
worked = 0.0
|
|
||||||
allocated = 0.0
|
|
||||||
if "(" in s:
|
|
||||||
try:
|
|
||||||
worked_part = s.split("(")[0].strip()
|
|
||||||
worked = float(worked_part)
|
|
||||||
except Exception:
|
|
||||||
worked = 0.0
|
|
||||||
try:
|
|
||||||
inner = s.split("(", 1)[1].split(")", 1)[0]
|
|
||||||
allocated = float(inner.strip())
|
|
||||||
except Exception:
|
|
||||||
allocated = worked
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
allocated = float(s)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
worked = 0.0
|
|
||||||
|
|
||||||
# store as tuple (worked, allocated) as expected by RotaBuilder
|
|
||||||
prev[col] = (worked, allocated)
|
|
||||||
|
|
||||||
if prev:
|
|
||||||
priors_map[initial] = prev
|
|
||||||
logger.debug("priors_map: {}", priors_map)
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"Prior allocations file not found: {priors_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error reading prior allocations: {e}")
|
|
||||||
|
|
||||||
# --- Example: Print the first few rows of each sheet ---
|
# --- Example: Print the first few rows of each sheet ---
|
||||||
#print("Days sheet:")
|
#print("Days sheet:")
|
||||||
@@ -236,132 +342,67 @@ def load_workers():
|
|||||||
rota_data = {}
|
rota_data = {}
|
||||||
for idx, row in days_df.iloc[7:].iterrows():
|
for idx, row in days_df.iloc[7:].iterrows():
|
||||||
# Extract initials from the name column (assumed to be in brackets at the end)
|
# Extract initials from the name column (assumed to be in brackets at the end)
|
||||||
text = str(row.iloc[1]).strip()
|
name = str(row.iloc[1]).strip()
|
||||||
if "(" in text and ")" in text:
|
initial = str(row.iloc[2]).strip()
|
||||||
initial = text.split("(")[-1].split(")")[0].strip()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Invalid format in row {idx}: {text}")
|
|
||||||
name = text.split("(")[0].strip()
|
|
||||||
if not name or name.lower() == "nan":
|
if not name or name.lower() == "nan":
|
||||||
continue
|
continue
|
||||||
availability = {}
|
availability = {}
|
||||||
requests = {}
|
requests = {}
|
||||||
rota_data[name] = f"rota {str(row.iloc[2]).strip().lower()}"
|
rota_data[name] = f"rota {str(row.iloc[3]).strip().lower()}"
|
||||||
for i, day in enumerate(weekday_cols):
|
for i, day in enumerate(weekday_cols):
|
||||||
val = row.iloc[3 + i]
|
val = row.iloc[4 + i]
|
||||||
# You can adjust the logic below depending on your marking scheme (e.g. "Y", "Yes", "1", etc.)
|
# You can adjust the logic below depending on your marking scheme (e.g. "Y", "Yes", "1", etc.)
|
||||||
available = not ("no" in str(val).strip().lower() or str(val).strip().lower() == "yes*")
|
available = not ("no" in str(val).strip().lower() or str(val).strip().lower() == "yes*")
|
||||||
availability[day] = available
|
availability[day] = available
|
||||||
workers_days.append({"initial": initial, "name": name, "availability": availability, "requests": requests})
|
workers_days.append({"initial": initial, "name": name, "availability": availability, "requests": requests})
|
||||||
|
|
||||||
|
|
||||||
leave_requests, work_requests = extract_leave_and_rota_from_calender(calender_df)
|
leave_requests, work_requests, calendar_workers = extract_leave_and_rota_from_calender(calender_df)
|
||||||
|
|
||||||
assignments, assignments_by_worker = extract_shift_assignments_from_rota(rota_df)
|
assignments, assignments_by_worker = extract_shift_assignments_from_rota(rota_df)
|
||||||
print(assignments)
|
print(assignments)
|
||||||
|
|
||||||
# --- Load per-day prior worked data (Sat/Sun) from sep2025priordays.csv for Rota A workers ---
|
# Validate cross-source worker matching before constructing Worker objects.
|
||||||
# Build set of initials who are on Rota A
|
days_workers = [w["name"] for w in workers_days]
|
||||||
rota_a_initials = set()
|
days_initials = [w["initial"] for w in workers_days]
|
||||||
for wd in workers_days:
|
|
||||||
name = wd["name"]
|
|
||||||
initial = wd["initial"]
|
|
||||||
if rota_data.get(name, "") == "rota a":
|
|
||||||
rota_a_initials.add(initial)
|
|
||||||
|
|
||||||
priordays_path = "cons/sep2025priordays.csv"
|
_validate_worker_alignment(
|
||||||
try:
|
base_workers=days_workers,
|
||||||
import csv
|
other_workers=calendar_workers,
|
||||||
|
base_label="Days tab worker names",
|
||||||
|
other_label="Calendar tab worker names",
|
||||||
|
)
|
||||||
|
|
||||||
perday_counts = defaultdict(lambda: defaultdict(int))
|
_validate_worker_alignment(
|
||||||
with open(priordays_path, newline="", encoding="utf-8") as f:
|
base_workers=days_initials,
|
||||||
reader = csv.reader(f)
|
other_workers=list(assignments_by_worker.keys()),
|
||||||
for row in reader:
|
base_label="Days tab worker initials",
|
||||||
if len(row) < 4:
|
other_label="Rota tab assigned initials",
|
||||||
continue
|
)
|
||||||
day = row[2].strip()
|
|
||||||
if day not in ("Sat", "Sun"):
|
|
||||||
continue
|
|
||||||
# fields from index 3 onwards are assignments; some may be empty
|
|
||||||
for field in row[3:]:
|
|
||||||
if not field:
|
|
||||||
continue
|
|
||||||
val = field.strip()
|
|
||||||
if not val:
|
|
||||||
continue
|
|
||||||
# entries may be like: RK (oncall, weekend) or CK (weekend b)
|
|
||||||
import re
|
|
||||||
|
|
||||||
m = re.match(r"\s*([A-Za-z0-9]+)\s*\(([^)]*)\)", val)
|
_validate_worker_alignment(
|
||||||
if not m:
|
base_workers=days_initials,
|
||||||
# sometimes multiple assignments are concatenated in a single cell separated by commas
|
other_workers=list(rota_b_assignments.keys()),
|
||||||
parts = [p.strip() for p in re.split(r",\s*", val) if p.strip()]
|
base_label="Days tab worker initials",
|
||||||
for p in parts:
|
other_label="Rota B assignments initials",
|
||||||
m2 = re.match(r"\s*([A-Za-z0-9]+)\s*\(([^)]*)\)", p)
|
)
|
||||||
if m2:
|
|
||||||
initial = m2.group(1).strip()
|
|
||||||
paren = m2.group(2).lower()
|
|
||||||
if initial in rota_a_initials and "weekend" in paren:
|
|
||||||
perday_counts[initial][day] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
initial = m.group(1).strip()
|
_validate_worker_alignment(
|
||||||
paren = m.group(2).lower()
|
base_workers=days_initials,
|
||||||
if initial in rota_a_initials and "weekend" in paren:
|
other_workers=list(priors_map.keys()),
|
||||||
perday_counts[initial][day] += 1
|
base_label="Days tab worker initials",
|
||||||
|
other_label="Running totals worker initials",
|
||||||
|
)
|
||||||
|
|
||||||
# Merge per-day counts into priors_map under 'weekend' per-day keys
|
logger.debug("Using Running totals priors for oncall/twilight/weekend A only")
|
||||||
for initial, daymap in perday_counts.items():
|
|
||||||
prev = priors_map.get(initial, {})
|
|
||||||
existing = prev.get("weekend")
|
|
||||||
# If existing is a tuple, convert to dict preserving overall tuple under '__all__'
|
|
||||||
if existing and not isinstance(existing, dict):
|
|
||||||
prev["weekend"] = {"__all__": existing}
|
|
||||||
existing = prev["weekend"]
|
|
||||||
|
|
||||||
for day, worked_count in daymap.items():
|
|
||||||
# Determine allocated value for this per-day entry
|
|
||||||
allocated_day = worked_count
|
|
||||||
if existing:
|
|
||||||
# prefer an explicit per-day allocated if present
|
|
||||||
if isinstance(existing, dict) and day in existing:
|
|
||||||
try:
|
|
||||||
allocated_day = float(existing[day][1])
|
|
||||||
except Exception:
|
|
||||||
allocated_day = worked_count
|
|
||||||
elif isinstance(existing, dict) and "__all__" in existing:
|
|
||||||
try:
|
|
||||||
overall_alloc = float(existing["__all__"][1])
|
|
||||||
allocated_day = overall_alloc / 2.0
|
|
||||||
except Exception:
|
|
||||||
allocated_day = worked_count
|
|
||||||
elif isinstance(existing, tuple):
|
|
||||||
try:
|
|
||||||
allocated_day = float(existing[1]) / 2.0
|
|
||||||
except Exception:
|
|
||||||
allocated_day = worked_count
|
|
||||||
|
|
||||||
# ensure structure
|
|
||||||
if "weekend" not in prev or not isinstance(prev["weekend"], dict):
|
|
||||||
prev.setdefault("weekend", {})
|
|
||||||
|
|
||||||
prev["weekend"][day] = (float(worked_count), float(allocated_day))
|
|
||||||
|
|
||||||
if prev:
|
|
||||||
priors_map[initial] = prev
|
|
||||||
|
|
||||||
logger.debug("priors_map after per-day merge: {}", priors_map)
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"Per-day prior file not found: {priordays_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error reading per-day priors: {e}")
|
|
||||||
|
|
||||||
workers = []
|
workers = []
|
||||||
for worker_day in workers_days:
|
for worker_day in workers_days:
|
||||||
worker = worker_day["name"]
|
worker = worker_day["name"]
|
||||||
initial = worker_day["initial"]
|
initial = worker_day["initial"]
|
||||||
|
|
||||||
start_date = "2026-02-16" # Default start date, can be adjusted later
|
start_date = ROTA_START_DATE
|
||||||
|
|
||||||
|
|
||||||
#if initial == "ND":
|
#if initial == "ND":
|
||||||
# start_date = "2025-09-29" # Non-working day worker starts later
|
# start_date = "2025-09-29" # Non-working day worker starts later
|
||||||
@@ -402,8 +443,10 @@ def load_workers():
|
|||||||
],
|
],
|
||||||
nwds=non_working_days,
|
nwds=non_working_days,
|
||||||
previous_shifts=priors_map.get(initial, {}),
|
previous_shifts=priors_map.get(initial, {}),
|
||||||
|
max_days_worked_per_week=2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
#if initial == "L1":
|
#if initial == "L1":
|
||||||
# w.oop = [OutOfProgramme(
|
# w.oop = [OutOfProgramme(
|
||||||
# start_date="2025-09-15",
|
# start_date="2025-09-15",
|
||||||
@@ -437,6 +480,7 @@ def load_workers():
|
|||||||
shift_name="weekend b",
|
shift_name="weekend b",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
w.add_hard_day_exclusion({"Sun", "Mon"})
|
||||||
if w.site == "rota a":
|
if w.site == "rota a":
|
||||||
|
|
||||||
w.prefer_multi_shift_together = 10
|
w.prefer_multi_shift_together = 10
|
||||||
@@ -444,19 +488,18 @@ def load_workers():
|
|||||||
#w.add_force_assign_with("twilight", "oncall")
|
#w.add_force_assign_with("twilight", "oncall")
|
||||||
w.add_force_assign_with("weekend", "oncall")
|
w.add_force_assign_with("weekend", "oncall")
|
||||||
|
|
||||||
if w.name in ("DS",):
|
if w.name in ("DS","RG","MOB","TB"):
|
||||||
w.add_hard_day_dependency({"Fri", "Sat", "Sun"}, ignore_dates=[datetime.date(2026, 5, 2)])
|
w.add_hard_day_dependency({"Fri", "Sat", "Sun"}, ignore_dates=[datetime.date(2026, 5, 2)])
|
||||||
|
w.max_days_worked_per_week = 3
|
||||||
else:
|
else:
|
||||||
|
w.max_days_worked_per_week = 2
|
||||||
|
w.add_max_days_per_week_block_constraint(max_days=2, week_block=2)
|
||||||
w.add_hard_day_dependency({"Fri", "Sun"})
|
w.add_hard_day_dependency({"Fri", "Sun"})
|
||||||
w.add_hard_day_exclusion({"Sat", "Sun"})
|
w.add_hard_day_exclusion({"Sat", "Sun"})
|
||||||
|
else:
|
||||||
|
w.max_unique_shifts_per_week_block = [MaxUniqueShiftsPerWeekBlockConstraint(week_block=1, max_unique_shifts=1)]
|
||||||
|
|
||||||
|
|
||||||
#if w.name == "TB":
|
|
||||||
# w.add_hard_day_exclusion({"Sat", "Sun"}, start_date="2025-11-17")
|
|
||||||
|
|
||||||
|
|
||||||
#w.set_max_shifts
|
|
||||||
#w.set_max_shifts_per_week("weekend", 1)
|
|
||||||
|
|
||||||
workers.append(w)
|
workers.append(w)
|
||||||
|
|
||||||
@@ -464,14 +507,37 @@ def load_workers():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_limit(t) -> int:
|
||||||
|
"""Parses a time limit string (e.g. '10h', '30m', '45s') or an integer to seconds."""
|
||||||
|
if isinstance(t, int):
|
||||||
|
return t
|
||||||
|
if isinstance(t, float):
|
||||||
|
return int(t)
|
||||||
|
t = str(t).strip().lower()
|
||||||
|
if not t:
|
||||||
|
return 0
|
||||||
|
if t.isdigit():
|
||||||
|
return int(t)
|
||||||
|
if t.endswith("h"):
|
||||||
|
return int(float(t[:-1]) * 3600)
|
||||||
|
if t.endswith("m"):
|
||||||
|
return int(float(t[:-1]) * 60)
|
||||||
|
if t.endswith("s"):
|
||||||
|
return int(float(t[:-1]))
|
||||||
|
try:
|
||||||
|
return int(float(t))
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid time format: {t}. Expected format like '10h', '30m', '45s' or a number of seconds.")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def main(
|
def main(
|
||||||
suspend: bool = False,
|
suspend: bool = False,
|
||||||
solve: bool = True,
|
solve: bool = True,
|
||||||
time_to_run: int = 60 * 60,
|
time_to_run: str = "1h",
|
||||||
ratio: float = 0.001,
|
ratio: float = 0.1,
|
||||||
start_date: datetime.datetime = "2026-02-16",
|
start_date: datetime.datetime = ROTA_START_DATE,
|
||||||
weeks: int = 24,
|
weeks: int = 22,
|
||||||
bom: int = 1,
|
bom: int = 1,
|
||||||
):
|
):
|
||||||
rota_start_date = start_date.date()
|
rota_start_date = start_date.date()
|
||||||
@@ -482,13 +548,13 @@ def main(
|
|||||||
weeks_to_rota=weeks,
|
weeks_to_rota=weeks,
|
||||||
balance_offset_modifier=bom,
|
balance_offset_modifier=bom,
|
||||||
use_previous_shifts=True,
|
use_previous_shifts=True,
|
||||||
name="cons rota feb 2026 run 2",
|
name="cons rota test",
|
||||||
allow_force_assignment_with_leave_conflict=True,
|
allow_force_assignment_with_leave_conflict=True,
|
||||||
constraint_options=RotaConstraintOptions(
|
constraint_options=RotaConstraintOptions(
|
||||||
balance_weekends=True,
|
balance_weekends=True,
|
||||||
max_shifts_per_week=6,
|
max_shifts_per_week=6,
|
||||||
max_weekend_frequency=3,
|
max_weekend_frequency=3,
|
||||||
max_days_per_week_block=[(4, 2), (4, 4)],
|
max_days_per_week_block=[(3, 2), (4, 4)],
|
||||||
#balance_weekend_days=True,
|
#balance_weekend_days=True,
|
||||||
balance_weekend_days_quadratic=True,
|
balance_weekend_days_quadratic=True,
|
||||||
weekend_day_hard_max_deviation=2,
|
weekend_day_hard_max_deviation=2,
|
||||||
@@ -509,8 +575,8 @@ def main(
|
|||||||
balance_offset=2,
|
balance_offset=2,
|
||||||
assign_as_block=False,
|
assign_as_block=False,
|
||||||
constraints=[
|
constraints=[
|
||||||
PreShiftConstraint(days=2, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), exclude_dates=["2026-04-06", "2026-05-04", "2026-05-25"]),
|
#PreShiftConstraint(days=2, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), exclude_dates=["2026-04-06", "2026-05-04", "2026-05-25"]),
|
||||||
PreShiftConstraint(days=1, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), allow_self=False),
|
#PreShiftConstraint(days=1, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), allow_self=False),
|
||||||
MaxShiftsPerWeekConstraint(max_shifts=1, days=days[:5]),
|
MaxShiftsPerWeekConstraint(max_shifts=1, days=days[:5]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -523,6 +589,8 @@ def main(
|
|||||||
balance_offset=1,
|
balance_offset=1,
|
||||||
constraints=[
|
constraints=[
|
||||||
PostShiftConstraint(days=1, start_date="2025-11-17", ignore_shifts=["oncall"]),
|
PostShiftConstraint(days=1, start_date="2025-11-17", ignore_shifts=["oncall"]),
|
||||||
|
#PreShiftConstraint(days=1),
|
||||||
|
#PostShiftConstraint(days=3),
|
||||||
],
|
],
|
||||||
display_char="a",
|
display_char="a",
|
||||||
#force_assign_with=["oncall"]
|
#force_assign_with=["oncall"]
|
||||||
@@ -530,12 +598,16 @@ def main(
|
|||||||
SingleShift(
|
SingleShift(
|
||||||
sites=("rota b", "rota c"),
|
sites=("rota b", "rota c"),
|
||||||
name="weekend b",
|
name="weekend b",
|
||||||
end_date="2026-07-20",
|
#end_date="2026-07-20",
|
||||||
workers_required=1,
|
workers_required=1,
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[5:],
|
days=days[5:],
|
||||||
balance_offset=2,
|
balance_offset=2,
|
||||||
display_char="b",
|
display_char="b",
|
||||||
|
constraints=[
|
||||||
|
PreShiftConstraint(days=2),
|
||||||
|
PostShiftConstraint(days=2),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
sites=(
|
sites=(
|
||||||
@@ -551,6 +623,7 @@ def main(
|
|||||||
constraints=[
|
constraints=[
|
||||||
PreShiftConstraint(days=1, start_date="2025-11-17", ignore_shifts=["oncall"], exclude_days=("Sat", "Sun")),
|
PreShiftConstraint(days=1, start_date="2025-11-17", ignore_shifts=["oncall"], exclude_days=("Sat", "Sun")),
|
||||||
MaxShiftsPerWeekConstraint(max_shifts=1),
|
MaxShiftsPerWeekConstraint(max_shifts=1),
|
||||||
|
MaxShiftsPerWeekBlockConstraint(week_block=3,max_shifts=1)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -648,8 +721,8 @@ def main(
|
|||||||
# Rota.build_workers()
|
# Rota.build_workers()
|
||||||
# Rota.build_model()
|
# Rota.build_model()
|
||||||
|
|
||||||
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
|
seconds_to_run = parse_time_limit(time_to_run)
|
||||||
# solver_options = {"seconds": time_to_run, "threads": 10}
|
solver_options = {"ratio": ratio, "seconds": seconds_to_run, "threads": 10}
|
||||||
|
|
||||||
# start_time = time.time()
|
# start_time = time.time()
|
||||||
Rota.build_and_solve(solver_options, export=True, export_with_timestamp=False, solve=solve, solver="appsi_highs")
|
Rota.build_and_solve(solver_options, export=True, export_with_timestamp=False, solve=solve, solver="appsi_highs")
|
||||||
@@ -681,6 +754,9 @@ def main(
|
|||||||
subprocess.run(
|
subprocess.run(
|
||||||
["scp", Rota.exported_rota_file, "ross@46.101.13.46:proc/proc-rota/output/"]
|
["scp", Rota.exported_rota_file, "ross@46.101.13.46:proc/proc-rota/output/"]
|
||||||
)
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["scp", "output/timetable.js", "ross@46.101.13.46:proc/proc-rota/output/"]
|
||||||
|
)
|
||||||
|
|
||||||
if suspend_on_finish:
|
if suspend_on_finish:
|
||||||
os.system("systemctl suspend")
|
os.system("systemctl suspend")
|
||||||
|
|||||||
+236
-113
@@ -2,7 +2,20 @@ import datetime
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from rota_generator.shifts import NoWorkers, RotaBuilder, SingleShift, WorkerRequirement, days
|
from rota_generator.shifts import (
|
||||||
|
NoWorkers,
|
||||||
|
RotaBuilder,
|
||||||
|
SingleShift,
|
||||||
|
WorkerRequirement,
|
||||||
|
days,
|
||||||
|
PreShiftConstraint,
|
||||||
|
PostShiftConstraint,
|
||||||
|
NightConstraint,
|
||||||
|
RequireRemoteSitePresenceConstraint,
|
||||||
|
LimitGradeNumberConstraint,
|
||||||
|
MinimumGradeNumberConstraint,
|
||||||
|
MaxShiftsPerWeekConstraint,
|
||||||
|
)
|
||||||
import typer
|
import typer
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@@ -17,6 +30,11 @@ sites = (
|
|||||||
"plymouth",
|
"plymouth",
|
||||||
"taunton",
|
"taunton",
|
||||||
"proc",
|
"proc",
|
||||||
|
"plymouth ir",
|
||||||
|
"truro ir",
|
||||||
|
"exeter ir",
|
||||||
|
"torbay ir",
|
||||||
|
"ir nights",
|
||||||
)
|
)
|
||||||
|
|
||||||
from rota_generator.workers import (
|
from rota_generator.workers import (
|
||||||
@@ -28,16 +46,38 @@ from rota_generator.workers import (
|
|||||||
OutOfProgramme,
|
OutOfProgramme,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def parse_time_limit(t) -> int:
|
||||||
|
"""Parses a time limit string (e.g. '10h', '30m', '45s') or an integer to seconds."""
|
||||||
|
if isinstance(t, int):
|
||||||
|
return t
|
||||||
|
if isinstance(t, float):
|
||||||
|
return int(t)
|
||||||
|
t = str(t).strip().lower()
|
||||||
|
if not t:
|
||||||
|
return 0
|
||||||
|
if t.isdigit():
|
||||||
|
return int(t)
|
||||||
|
if t.endswith("h"):
|
||||||
|
return int(float(t[:-1]) * 3600)
|
||||||
|
if t.endswith("m"):
|
||||||
|
return int(float(t[:-1]) * 60)
|
||||||
|
if t.endswith("s"):
|
||||||
|
return int(float(t[:-1]))
|
||||||
|
try:
|
||||||
|
return int(float(t))
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid time format: {t}. Expected format like '10h', '30m', '45s' or a number of seconds.")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def main(
|
def main(
|
||||||
suspend: bool = False,
|
suspend: bool = False,
|
||||||
solve: bool = True,
|
solve: bool = True,
|
||||||
time_to_run: int = 60 * 60 * 4,
|
time_to_run: str = "10h",
|
||||||
ratio: float = 0.001,
|
ratio: float = 0.1,
|
||||||
start_date: datetime.datetime = "2025-09-01",
|
start_date: datetime.datetime = "2026-09-07",
|
||||||
weeks: int = 16,
|
weeks: int = 26,
|
||||||
bom: int = 2,
|
bom: float = 1,
|
||||||
):
|
):
|
||||||
rota_start_date = start_date.date()
|
rota_start_date = start_date.date()
|
||||||
suspend_on_finish = suspend
|
suspend_on_finish = suspend
|
||||||
@@ -46,19 +86,20 @@ def main(
|
|||||||
rota_start_date,
|
rota_start_date,
|
||||||
weeks_to_rota=weeks,
|
weeks_to_rota=weeks,
|
||||||
balance_offset_modifier=bom,
|
balance_offset_modifier=bom,
|
||||||
name="proc_rota",
|
name="proc_rota_sep_2026",
|
||||||
)
|
)
|
||||||
# Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
|
# Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
|
||||||
Rota.constraint_options["balance_shifts"] = False
|
Rota.constraint_options["balance_shifts"] = False
|
||||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||||
Rota.constraint_options["balance_weekends"] = True
|
Rota.constraint_options["balance_weekends"] = True
|
||||||
Rota.constraint_options["max_night_frequency"] = 3 # 3
|
Rota.constraint_options["max_night_frequency"] = 3 # 3
|
||||||
Rota.constraint_options["max_night_frequency_week_exclusions"] = []
|
#Rota.constraint_options["max_night_frequency_week_exclusions"] = []
|
||||||
Rota.constraint_options["max_weekend_frequency"] = 2
|
Rota.constraint_options["max_weekend_frequency"] = 3
|
||||||
Rota.constraint_options["hard_constrain_pair_separation"] = True
|
|
||||||
# Rota.constraint_options["avoid_st2_first_month"] = True
|
|
||||||
|
|
||||||
#wr = [
|
Rota.constraint_options["max_days_per_week_block"] = [(8,3)]
|
||||||
|
Rota.constraint_options["maximum_allowed_shift_diff"] = 1
|
||||||
|
|
||||||
|
# wr = [
|
||||||
# WorkerRequirement(
|
# WorkerRequirement(
|
||||||
# end_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
# end_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
||||||
# number=3,
|
# number=3,
|
||||||
@@ -67,7 +108,7 @@ def main(
|
|||||||
# start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
# start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
||||||
# number=4,
|
# number=4,
|
||||||
# ),
|
# ),
|
||||||
#]
|
# ]
|
||||||
|
|
||||||
Rota.add_shifts(
|
Rota.add_shifts(
|
||||||
SingleShift(
|
SingleShift(
|
||||||
@@ -76,17 +117,13 @@ def main(
|
|||||||
"exeter twilights and weekends",
|
"exeter twilights and weekends",
|
||||||
"exeter no nights",
|
"exeter no nights",
|
||||||
"exeter twilights",
|
"exeter twilights",
|
||||||
|
"exeter ir",
|
||||||
),
|
),
|
||||||
name="exeter_twilight",
|
name="exeter_twilight",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[:5],
|
days=days[:5],
|
||||||
balance_offset=4,
|
balance_offset=2,
|
||||||
constraint=[
|
constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)],
|
||||||
{
|
|
||||||
"name": "max_shifts_per_week",
|
|
||||||
"options": 2,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
sites=(
|
sites=(
|
||||||
@@ -96,18 +133,25 @@ def main(
|
|||||||
"truro twilights and weekends",
|
"truro twilights and weekends",
|
||||||
"truro twilights and weekend nights",
|
"truro twilights and weekend nights",
|
||||||
"truro twilights, weekends and weekend nights",
|
"truro twilights, weekends and weekend nights",
|
||||||
|
"truro ir",
|
||||||
),
|
),
|
||||||
name="truro_twilight",
|
name="truro_twilight",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[:4],
|
days=days[:4],
|
||||||
balance_offset=4,
|
balance_offset=4,
|
||||||
|
constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)],
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
sites=("torbay", "torbay twilights", "torbay twilights and weekends"),
|
sites=(
|
||||||
|
"torbay",
|
||||||
|
"torbay twilights",
|
||||||
|
"torbay twilights and weekends",
|
||||||
|
"torbay ir",
|
||||||
|
),
|
||||||
name="torbay_twilight",
|
name="torbay_twilight",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[:4],
|
days=days[:4],
|
||||||
balance_offset=4,
|
balance_offset=2,
|
||||||
assign_as_block=True,
|
assign_as_block=True,
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
@@ -116,13 +160,14 @@ def main(
|
|||||||
"plymouth twilights",
|
"plymouth twilights",
|
||||||
"plymouth twilights and weekends",
|
"plymouth twilights and weekends",
|
||||||
"plymouth twilights, weekends and weekend nights",
|
"plymouth twilights, weekends and weekend nights",
|
||||||
|
"plymouth ir",
|
||||||
),
|
),
|
||||||
name="plymouth_twilight",
|
name="plymouth_twilight",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[:5],
|
days=days[:5],
|
||||||
balance_offset=4,
|
balance_offset=2,
|
||||||
workers_required=1,
|
workers_required=1,
|
||||||
constraint=[{"name": "max_shifts_per_week", "options": 2}],
|
constraints=[MaxShiftsPerWeekConstraint(max_shifts=2)],
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
sites=(
|
sites=(
|
||||||
@@ -130,14 +175,15 @@ def main(
|
|||||||
"exeter twilights and weekends",
|
"exeter twilights and weekends",
|
||||||
"exeter no nights",
|
"exeter no nights",
|
||||||
"exeter weekends",
|
"exeter weekends",
|
||||||
|
# "exeter twilights",
|
||||||
),
|
),
|
||||||
name="weekend_exeter",
|
name="weekend_exeter",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[5:],
|
days=days[5:],
|
||||||
balance_offset=3,
|
# balance_offset=2,
|
||||||
rota_on_nwds=True,
|
rota_on_nwds=True,
|
||||||
force_as_block=True,
|
force_as_block=True,
|
||||||
constraint=[{"name": "post", "options": 2}, {"name": "pre", "options": 2}],
|
constraints=[PostShiftConstraint(days=2), PreShiftConstraint(days=2)],
|
||||||
# constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},],
|
# constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},],
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
@@ -151,22 +197,22 @@ def main(
|
|||||||
name="weekend_truro",
|
name="weekend_truro",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[4:],
|
days=days[4:],
|
||||||
balance_offset=3,
|
# balance_offset=3,
|
||||||
# rota_on_nwds=True,
|
#rota_on_nwds=True,
|
||||||
# force_as_block=True,
|
#force_as_block=True,
|
||||||
assign_as_block=True,
|
# assign_as_block=True,
|
||||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||||
# force_as_block_unless_nwd=True
|
force_as_block_unless_nwd=[["Fri"], ["Sat", "Sun"]],
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
sites=("torbay", "torbay twilights and weekends"),
|
sites=("torbay", "torbay twilights and weekends"),
|
||||||
name="weekend_torbay",
|
name="weekend_torbay",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[4:],
|
days=days[4:],
|
||||||
balance_offset=3,
|
# balance_offset=2,
|
||||||
rota_on_nwds=True,
|
rota_on_nwds=True,
|
||||||
force_as_block=True,
|
force_as_block=True,
|
||||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||||
# force_as_block_unless_nwd=True
|
# force_as_block_unless_nwd=True
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
@@ -179,11 +225,11 @@ def main(
|
|||||||
name="weekend_plymouth1",
|
name="weekend_plymouth1",
|
||||||
length=8,
|
length=8,
|
||||||
days=days[5:],
|
days=days[5:],
|
||||||
balance_offset=2.9,
|
# balance_offset=2.9,
|
||||||
workers_required=1,
|
workers_required=1,
|
||||||
rota_on_nwds=True,
|
rota_on_nwds=True,
|
||||||
force_as_block=True,
|
force_as_block=True,
|
||||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
sites=(
|
sites=(
|
||||||
@@ -194,11 +240,11 @@ def main(
|
|||||||
name="weekend_plymouth2",
|
name="weekend_plymouth2",
|
||||||
length=8,
|
length=8,
|
||||||
days=days[5:],
|
days=days[5:],
|
||||||
balance_offset=2.9,
|
# balance_offset=2.9,
|
||||||
workers_required=1,
|
workers_required=1,
|
||||||
rota_on_nwds=True,
|
rota_on_nwds=True,
|
||||||
force_as_block=True,
|
force_as_block=True,
|
||||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
sites=(
|
sites=(
|
||||||
@@ -206,6 +252,7 @@ def main(
|
|||||||
"plymouth twilights",
|
"plymouth twilights",
|
||||||
"plymouth twilights and weekends",
|
"plymouth twilights and weekends",
|
||||||
"plymouth twilights, weekends and weekend nights",
|
"plymouth twilights, weekends and weekend nights",
|
||||||
|
"plymouth ir",
|
||||||
),
|
),
|
||||||
name="plymouth_bank_holidays",
|
name="plymouth_bank_holidays",
|
||||||
length=8,
|
length=8,
|
||||||
@@ -217,28 +264,27 @@ def main(
|
|||||||
bank_holidays_only=True,
|
bank_holidays_only=True,
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
sites=sites,
|
sites=[
|
||||||
|
*sites,
|
||||||
|
],
|
||||||
name="night_weekday",
|
name="night_weekday",
|
||||||
length=12.25,
|
length=12.25,
|
||||||
days=days[:4],
|
days=days[:4],
|
||||||
balance_offset=3.9,
|
# balance_offset=3.9,
|
||||||
balance_weighting=1,
|
balance_weighting=1,
|
||||||
# hard_constrain_shift=False,
|
# hard_constrain_shift=False,
|
||||||
workers_required=4,
|
workers_required=4,
|
||||||
force_as_block=True,
|
force_as_block=True,
|
||||||
rota_on_nwds=True,
|
rota_on_nwds=True,
|
||||||
constraint=[
|
constraints=[
|
||||||
{"name": "night"},
|
NightConstraint(),
|
||||||
{"name": "pre", "options": 2},
|
PreShiftConstraint(days=2),
|
||||||
{"name": "post", "options": 2},
|
PostShiftConstraint(days=2),
|
||||||
{
|
RequireRemoteSitePresenceConstraint(site="plymouth", required_number=1),
|
||||||
"name": "require_remote_site_presence_week",
|
LimitGradeNumberConstraint(grade=2, max_number=2),
|
||||||
"options": ("plymouth", 1),
|
MinimumGradeNumberConstraint(grade=4, min_number=1),
|
||||||
},
|
|
||||||
{"name": "limit_grade_number", "options": {2: 1}},
|
|
||||||
{"name": "minimum_grade_number", "options": (4, 1)},
|
|
||||||
],
|
],
|
||||||
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
# end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
sites=[
|
sites=[
|
||||||
@@ -251,32 +297,36 @@ def main(
|
|||||||
name="night_weekend",
|
name="night_weekend",
|
||||||
length=12.25,
|
length=12.25,
|
||||||
days=days[4:],
|
days=days[4:],
|
||||||
balance_offset=2.9,
|
# balance_offset=2.9,
|
||||||
balance_weighting=1,
|
balance_weighting=1,
|
||||||
# hard_constrain_shift=False,
|
# hard_constrain_shift=False,
|
||||||
workers_required=4,
|
workers_required=4,
|
||||||
force_as_block=True,
|
force_as_block=True,
|
||||||
rota_on_nwds=True,
|
rota_on_nwds=True,
|
||||||
constraint=[
|
constraints=[
|
||||||
{"name": "night"},
|
NightConstraint(),
|
||||||
{"name": "pre", "options": 2},
|
PreShiftConstraint(days=2),
|
||||||
{"name": "post", "options": 3},
|
PostShiftConstraint(days=3),
|
||||||
{
|
RequireRemoteSitePresenceConstraint(site="plymouth", required_number=1),
|
||||||
"name": "require_remote_site_presence_week",
|
LimitGradeNumberConstraint(grade=2, max_number=2),
|
||||||
"options": ("plymouth", 1),
|
MinimumGradeNumberConstraint(grade=4, min_number=1),
|
||||||
},
|
|
||||||
{"name": "limit_grade_number", "options": {2: 2}},
|
|
||||||
{"name": "minimum_grade_number", "options": (4, 1)},
|
|
||||||
],
|
],
|
||||||
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
# end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prevent shifts for ST2s for 2 weeks
|
# Prevent shifts for ST2s for 2 weeks
|
||||||
Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
|
# Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
|
||||||
|
|
||||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6)
|
Rota.add_min_summed_grade_by_shifts_per_day_constraint(
|
||||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6)
|
["weekend_plymouth1", "weekend_plymouth2"], 6
|
||||||
|
)
|
||||||
|
Rota.add_min_summed_grade_by_shifts_per_day_constraint(
|
||||||
|
["plymouth_twilight", "plymouth_bank_holidays"], 6
|
||||||
|
)
|
||||||
|
|
||||||
|
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekday"], 12)
|
||||||
|
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekend"], 12)
|
||||||
|
|
||||||
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||||
|
|
||||||
@@ -287,8 +337,43 @@ def main(
|
|||||||
if load_leave:
|
if load_leave:
|
||||||
import leave
|
import leave
|
||||||
|
|
||||||
|
# Internal-only option: remove leave requests between two dates.
|
||||||
|
# Configure by editing the two variables below (not exposed via CLI).
|
||||||
|
# Use strings in 'YYYY-MM-DD' or datetime.date objects. Set either to None to disable.
|
||||||
|
REMOVE_LEAVE_FROM = ""
|
||||||
|
REMOVE_LEAVE_TO = ""
|
||||||
|
|
||||||
workers = leave.load_leave(Rota)
|
workers = leave.load_leave(Rota)
|
||||||
|
|
||||||
|
# If the internal remove range is set, filter out NotAvailableToWork entries
|
||||||
|
if REMOVE_LEAVE_FROM and REMOVE_LEAVE_TO:
|
||||||
|
# Coerce to date objects if strings provided
|
||||||
|
def _to_date(v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d/%m/%y"):
|
||||||
|
try:
|
||||||
|
return datetime.datetime.strptime(v, fmt).date()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Cannot parse date: {v}")
|
||||||
|
if isinstance(v, datetime.datetime):
|
||||||
|
return v.date()
|
||||||
|
return v
|
||||||
|
|
||||||
|
_from = _to_date(REMOVE_LEAVE_FROM)
|
||||||
|
_to = _to_date(REMOVE_LEAVE_TO)
|
||||||
|
|
||||||
|
for name, w in list(workers.items()):
|
||||||
|
original = w.get("leave", [])
|
||||||
|
before_count = len(original)
|
||||||
|
filtered = [lr for lr in original if not (_from <= lr.date <= _to)]
|
||||||
|
after_count = len(filtered)
|
||||||
|
if after_count != before_count:
|
||||||
|
print(
|
||||||
|
f"Removed {before_count - after_count} leave entries for '{name}' between {_from} and {_to}"
|
||||||
|
)
|
||||||
|
w["leave"] = filtered
|
||||||
|
|
||||||
n = 0
|
n = 0
|
||||||
for worker in workers:
|
for worker in workers:
|
||||||
worker_name = worker
|
worker_name = worker
|
||||||
@@ -301,13 +386,18 @@ def main(
|
|||||||
site = w["site"]
|
site = w["site"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
print(f"Worker {worker} has no site")
|
print(f"Worker {worker} has no site")
|
||||||
raise KeyError
|
print(w)
|
||||||
|
sys.exit(1)
|
||||||
grade = w["grade"]
|
grade = w["grade"]
|
||||||
try:
|
try:
|
||||||
fte = float(w["fte"]) * 100
|
fte = float(w["fte"]) * 100
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print(f"{worker} has invalid fte: {fte}")
|
print(f"{worker} has invalid fte: {fte}")
|
||||||
raise ValueError
|
raise ValueError
|
||||||
|
except KeyError:
|
||||||
|
print(f"Worker {worker} has no FTE")
|
||||||
|
print(w)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
nwd = w["nwd"]
|
nwd = w["nwd"]
|
||||||
end_date = w["end_date"]
|
end_date = w["end_date"]
|
||||||
@@ -328,9 +418,9 @@ def main(
|
|||||||
nwd_end_date = Rota.rota_end_date
|
nwd_end_date = Rota.rota_end_date
|
||||||
|
|
||||||
if "[" in i:
|
if "[" in i:
|
||||||
print("Split nwd", worker_name, i)
|
# print("Split nwd", worker_name, i)
|
||||||
a, b = i.split("[")[1][:-1].split("-")
|
a, b = i.split("[")[1][:-1].split("-")
|
||||||
print(f"{a=} {b=}")
|
# print(f"{a=} {b=}")
|
||||||
nwd_start_date = datetime.datetime.strptime(
|
nwd_start_date = datetime.datetime.strptime(
|
||||||
a, "%d/%m/%Y"
|
a, "%d/%m/%Y"
|
||||||
).date()
|
).date()
|
||||||
@@ -349,47 +439,48 @@ def main(
|
|||||||
if oop:
|
if oop:
|
||||||
formatted_oops = []
|
formatted_oops = []
|
||||||
for dates in oop.split(","):
|
for dates in oop.split(","):
|
||||||
print(dates)
|
raw = dates.strip()
|
||||||
if "-" in dates:
|
# strip surrounding brackets/parentheses/quotes
|
||||||
s, e = dates.split("-")
|
raw = raw.strip("()[]\"' ")
|
||||||
elif "to" in dates:
|
if "-" in raw:
|
||||||
s, e = dates.split(" to ")
|
s, e = raw.split("-", 1)
|
||||||
|
elif " to " in raw:
|
||||||
|
s, e = raw.split(" to ", 1)
|
||||||
|
elif "to" in raw:
|
||||||
|
s, e = raw.split("to", 1)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Cannot parse OOP dates: '{oop}' for {worker}")
|
raise ValueError(
|
||||||
try:
|
f"Cannot parse OOP dates: '{oop}' for {worker}"
|
||||||
formatted_oops.append(
|
|
||||||
{
|
|
||||||
"start_date": datetime.datetime.strptime(
|
|
||||||
s.strip(), "%d/%m/%Y"
|
|
||||||
).date(),
|
|
||||||
"end_date": datetime.datetime.strptime(
|
|
||||||
e.strip(), "%d/%m/%Y"
|
|
||||||
).date(),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
except ValueError:
|
|
||||||
|
s = s.strip().strip("()[]\"' ")
|
||||||
|
e = e.strip().strip("()[]\"' ")
|
||||||
|
|
||||||
|
parsed = False
|
||||||
|
for fmt in ("%d/%m/%Y", "%d/%m/%y"):
|
||||||
try:
|
try:
|
||||||
|
start_dt = datetime.datetime.strptime(s, fmt).date()
|
||||||
|
end_dt = datetime.datetime.strptime(e, fmt).date()
|
||||||
formatted_oops.append(
|
formatted_oops.append(
|
||||||
{
|
{"start_date": start_dt, "end_date": end_dt}
|
||||||
"start_date": datetime.datetime.strptime(
|
|
||||||
s.strip(), "%d/%m/%y"
|
|
||||||
).date(),
|
|
||||||
"end_date": datetime.datetime.strptime(
|
|
||||||
e.strip(), "%d/%m/%y"
|
|
||||||
).date(),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
parsed = True
|
||||||
print(e)
|
break
|
||||||
print("WORKER", worker)
|
except ValueError:
|
||||||
print("DATES", s, e)
|
continue
|
||||||
raise
|
|
||||||
|
if not parsed:
|
||||||
|
print("WORKER", worker)
|
||||||
|
print("DATES", s, e)
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot parse OOP dates: '{oop}' for {worker}"
|
||||||
|
)
|
||||||
|
|
||||||
oop = formatted_oops
|
oop = formatted_oops
|
||||||
else:
|
else:
|
||||||
oop = []
|
oop = []
|
||||||
|
|
||||||
print(f"{worker} OOP {oop}")
|
# print(f"{worker} OOP {oop}")
|
||||||
|
|
||||||
previous_shifts = {}
|
previous_shifts = {}
|
||||||
# if twi:
|
# if twi:
|
||||||
@@ -423,21 +514,50 @@ def main(
|
|||||||
|
|
||||||
shift_fte_overrides = {}
|
shift_fte_overrides = {}
|
||||||
|
|
||||||
#if worker_name == "Ben Kemp":
|
exact_shifts = {}
|
||||||
|
|
||||||
|
if worker in ["hannah.lewis29@nhs.net", "shruti.bodapati@nhs.net", "a.abouelatta@nnhs.net"]:
|
||||||
|
exact_shifts = {
|
||||||
|
"night_weekday": 4,
|
||||||
|
"night_weekend": 3,
|
||||||
|
}
|
||||||
|
elif worker in ["g.abdelhalim@nhs.net", "nang.thiriphoo@nhs.net"]:
|
||||||
|
exact_shifts = {
|
||||||
|
"night_weekend": 3,
|
||||||
|
"night_weekday": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
override_shift_start_dates = []
|
||||||
|
if worker in ["Anushka Kulkarni"]:
|
||||||
|
override_shift_start_dates = [
|
||||||
|
{
|
||||||
|
"shift": "night_weekday",
|
||||||
|
"start_date": datetime.datetime.strptime(
|
||||||
|
"2026-11-01", "%Y-%m-%d"
|
||||||
|
).date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"shift": "night_weekend",
|
||||||
|
"start_date": datetime.datetime.strptime(
|
||||||
|
"2026-11-01", "%Y-%m-%d"
|
||||||
|
).date(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# if worker_name == "Hadi Mohamed":
|
||||||
# shift_fte_overrides = {
|
# shift_fte_overrides = {
|
||||||
# "plymouth_twilight": 100,
|
# "weekend_exeter": 50,
|
||||||
# "weekend_plymouth1": 50,
|
|
||||||
# "weekend_plymouth2": 50,
|
|
||||||
# }
|
# }
|
||||||
#elif worker_name == "Joel Lim":
|
# elif worker_name == "Joel Lim":
|
||||||
# shift_fte_overrides = {
|
# shift_fte_overrides = {
|
||||||
# "plymouth_twilight": 100,
|
# "plymouth_twilight": 100,
|
||||||
# "weekend_exeter": 50,
|
# "weekend_exeter": 50,
|
||||||
# }
|
# }
|
||||||
if worker_name == "Nang Thiriphoo":
|
# if worker_name == "Nang Thiriphoo":
|
||||||
shift_fte_overrides = {
|
# shift_fte_overrides = {
|
||||||
"weekend_exeter": 40,
|
# "weekend_exeter": 40,
|
||||||
}
|
# }
|
||||||
|
|
||||||
w = Worker(
|
w = Worker(
|
||||||
name=worker_name,
|
name=worker_name,
|
||||||
@@ -457,20 +577,23 @@ def main(
|
|||||||
shift_balance_extra=w["shift_balance_extra"],
|
shift_balance_extra=w["shift_balance_extra"],
|
||||||
bank_holiday_extra=w["bank_holiday_extra"],
|
bank_holiday_extra=w["bank_holiday_extra"],
|
||||||
shift_fte_overrides=shift_fte_overrides,
|
shift_fte_overrides=shift_fte_overrides,
|
||||||
|
exact_shifts=exact_shifts,
|
||||||
|
shift_start_dates=override_shift_start_dates,
|
||||||
)
|
)
|
||||||
|
|
||||||
print(w)
|
# print(w)
|
||||||
|
|
||||||
Rota.add_worker(w)
|
Rota.add_worker(w)
|
||||||
|
leave.load_academy(Rota)
|
||||||
|
|
||||||
# Rota.build_workers()
|
# Rota.build_workers()
|
||||||
# Rota.build_model()
|
# Rota.build_model()
|
||||||
|
|
||||||
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
|
seconds_to_run = parse_time_limit(time_to_run)
|
||||||
# solver_options = {"seconds": time_to_run, "threads": 10}
|
solver_options = {"ratio": ratio, "seconds": seconds_to_run, "threads": 10}
|
||||||
|
|
||||||
# start_time = time.time()
|
# start_time = time.time()
|
||||||
Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs")
|
Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs", export_with_timestamp=True)
|
||||||
|
|
||||||
# Rota.solve_shifts_by_block(solver_options, block_length=13)
|
# Rota.solve_shifts_by_block(solver_options, block_length=13)
|
||||||
# Rota.solve_shifts_individually(solver_options)
|
# Rota.solve_shifts_individually(solver_options)
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
Rota 2021/2022,,, (POSSIBLE IDT),,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,80% until dec 1st,,,,,,,,,,,,,,,,
|
|
||||||
Name,,Stephanie Bailey,Luciana Calovi Motschenbacher,Layla Bell,Seren Peters,Alex wood,Salma Aslam,Max Ireland,Charles Finan,Paul Ward,Tom Welsh,Delilah Trimmer,Max Finzel,Zainab Sharaf,Maththew O'Brien,Kyaw Tint,Nicolas Dziadulewicz,Jean Skumar,Michael Tomek,Vilim Kalamar,Dhuvresh Patel,Khalil Madbak,Georgina Edwards,Jenn Haw fong,Paul Jenkins,Jenna Millington,Amoolya Mannava,Ross Kruger,Sophie McGlade,Rebecca Murphy,Fiona Lyall,Joel Lim,Wijhdan Abusrewil,Mark Haley,Andrew MacCormick,Alexander Sanchez - cabello,Sayed Hashim Alqarooni,Chong Yew Ng,Matthew Thorley,Harriet Conley,Jon Skinner,Cameron Bullock,Alexander Wijnberg,Christian Greer,Alan Eccles,Hannah Lewis,Sarath Vennam,Amina Odeh,Mo Babsail,Richard Chaytor,Sherafghan PROC,Madalina Drumea,Mark Fellows,Clement Leung
|
|
||||||
Rotation,,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford no proc,Derriford,Derriford,Derriford,Derriford,Derriford Twilights,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay Twilights,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro no PROC,Barnstaple,Taunton,Taunton
|
|
||||||
Grade,,ST2,ST2,ST2,ST3,ST3,ST3,ST3,ST4,ST4,ST5,ST5,ST2,ST2,ST2,ST2,ST3,ST3,ST3,ST3,ST3,ST4,ST4,ST4,ST4,ST4,ST5,ST5,ST5,ST5,ST6,ST2,ST2,ST2,ST2,ST3,ST4,ST4,ST4,ST5,ST6,ST2,ST2,ST3,ST3,ST3,ST4,ST4,ST5,ST5,ST5 (until dec),ST3,ST2,ST4
|
|
||||||
PROC site,,Derriford,,exeter,Torbay,Exeter,,Derriford,Derriford,Exeter,Exeter,Derriford,Derriford,torbay,Torbay,derriford,,derriford,exeter,,Exeter,,,derriford,Derriford,,derriford,Derriford,Derriford,Exeter,NA,derriford,Torbay,Derriford,Derriford,,torbay,Derriford,Derriford,Torbay,NA,Exeter,Derriford,Truro,Truro,Derriford,Truro,Truro,Derriford,derriford,Derriford,Barnstaple,NA,NA
|
|
||||||
%FTE,,0,0,100,100,100,0,100,100,100,100,80,100,100,100,100,100,60,80,100,100,0,80,100,100,0,100,100,0,100,60,100,100,100,100,100,100,100,100,60,100,100,100,100,80,100,100,100,100,100,100,80,0,0
|
|
||||||
NWD,,,,,,,"Thursday,Friday",,"Tuesday,Thursday",,,wednesday,,,,,,"Wednesday,thursday",Wednesday,,,,Friday,,,"thursday, friday",,,Friday,,"monday,friday",,"thursday, friday",,,,,,,"monday,thursday",,,,,"wednesday, thursday,Friday",,,,,,,,,
|
|
||||||
Flexible NWD,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
End Date,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
OOP,,,,,,,6/9/21-31/3/22,,,,,,,,,,,,,,,,,,,6/9/21-2/5/22,,,1/1/22-5/9/22,6/9/21-28/2/22,,,1/3/22-31/3/22,,,,,,,6/9/21-20/9/21,,,,,,,,,1/8/22-4/9/22,23/7/22-4/9/22,,,,
|
|
||||||
Group,,Y1G1,Y1G1,Y1G2,Y3G1,Y3G1,Y3G2,Y3G2,,,,,Y2G1,Y2G2,Y2G2,Y2G2,Y3G1,Y3G1,Y3G2,Y3G2,Y3G2,,,,,,,,,,,Y2G1,Y2G1,Y2G2,Y2G2,Y3G2,,,,,,Y2G1,Y2G2,Y3G1,Y3G1,Y3G2,,,,,,,Y2G1,
|
|
||||||
?PROC,,,,,,,,,,,,,,,,,,,,,,NO,,,,WEEKEND ONLY,,,,,No,,,,,,,,,,No,,,,,,,,,,Yes - until ST6 in dec,???,NO,NO
|
|
||||||
Pair,,,,,,,,,,,,,,1,,,,,,,,,,,,,,,,,,,,,,,1,,,,,,,,,,,,,,2,,,
|
|
||||||
Extra_night_weekday,,3.72,,-0.28,-0.28,-0.28,,-0.28,-0.28,-0.28,-0.28,0.63,-0.28,-0.28,-0.28,-0.28,-0.28,4.63,2.17,-0.28,3.72,,2.17,-0.28,-0.28,,-0.28,-0.28,,0.3,0,-0.28,-0.28,-0.28,3.72,-0.28,-0.28,-1.06,-0.28,0.27,,-0.28,-0.28,-0.28,-1.83,-0.28,-0.28,-0.28,-0.28,-0.28,0,-0.28,,
|
|
||||||
Extra_night_weekend,,-0.21,,-0.21,-0.21,-0.21,,-0.21,-0.21,-0.21,-0.21,0.47,-0.21,2.79,2.79,2.79,-0.21,-2.53,-1.37,-0.21,-0.21,,-1.37,-0.21,-0.21,,-0.21,-0.21,,0.22,0,-0.21,-0.21,-0.21,-0.21,-0.21,-0.21,2.21,-0.21,0.21,,-0.21,-0.21,-0.21,1.63,-0.21,-0.21,-0.21,-0.21,-0.21,0,2.79,,
|
|
||||||
Extra_twilight,,-1.97,,-0.97,1.03,1.03,,-0.97,-1.97,1.03,1.03,-0.38,0.47,0.47,-1.53,0.47,-0.53,-3.92,-2.23,0.47,-2.53,,-2.23,0.47,0.47,,0.47,0.47,,0.33,0.08,,,,,,,,,,,-1.39,-1.39,0.61,0.49,-1.39,-0.39,1.61,2.61,-1.39,0.61,0,,
|
|
||||||
Extra_weekend,,-0.73,,2.27,-0.73,-0.73,,2.27,2.27,-0.73,-0.73,-1.04,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,1.96,-1.04,-1.04,-0.63,1.96,0.96,-1.04,-2.04,1.96,-1.04,0,,
|
|
||||||
Extra_weekend_plymouth1,,,,,,,,,,,,,-2.22,-0.22,-2.22,-0.22,-0.22,2.27,-0.98,-2.22,-0.22,,1.02,1.78,-0.22,,1.78,1.78,,0.15,0,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
Extra_weekend_plymouth2,,,,,,,,,,,,,1.78,-2.22,1.78,-2.22,-0.22,0.27,3.02,1.78,-0.22,,1.02,-2.22,-0.22,,-2.22,-0.22,,0.15,0,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
Extra_plymouth_bank_holiday,,,,,,,,,,,,,0.2,0.2,0.2,0.2,0.2,0.12,-0.84,0.2,0.2,,0.16,0.2,0.2,,0.2,-1.8,,0.01,0.12,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
bank_holidays_worked,,,,1,,,,1,,2,1,3,,2,1,2,,2,1,,3,,,,,,2,2,,,,2,,2,,1,,1,2,,,,2,2,,,,2,,,1,,,
|
|
||||||
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
Date,Exams,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
6/9/21,,,NO ONCALL,,,POST ONCALL,Mat leave,,exeter_twilight,,exeter_twilight,,,A/L,,,,,A/L,,,No Oncall,POST ONCALL,POST ONCALL,POST ONCALL,Mat leave,,,plymouth_twilight,OOPT,NO oncals,,POST ONCALL,,,,A/L,torbay_twilight,torbay_twilight,Mat leave,POST ONCALL,,,,,truro_twilight,truro_twilight,,A/L,POST ONCALL,,,,
|
|
||||||
7/9/21,,,NO ONCALL,,,POST ONCALL,Mat leave,,exeter_twilight,,exeter_twilight,,,A/L,,,,,A/L,,,No Oncall,POST ONCALL,POST ONCALL,POST ONCALL,Mat leave,,,plymouth_twilight,OOPT,NO oncals,,POST ONCALL,,,,A/L,torbay_twilight,torbay_twilight,Mat leave,POST ONCALL,,,,,truro_twilight,truro_twilight,,A/L,POST ONCALL,,,,
|
|
||||||
8/9/21,,,NO ONCALL,,,,Mat leave,,2b course,2b course,,,,,,,,,A/L,,,No Oncall,2b course,2b course,,Mat leave,,2b course,,OOPT,NO oncals,,,,,,A/L,2b course,2b course,Mat leave,,,,,,,2b course,2b course,A/L,,,,,
|
|
||||||
9/9/21,,,NO ONCALL,,,,Mat leave,,2b course,2b course,,,,,,,,,A/L,,,No Oncall,2b course,2b course,,Mat leave,,2b course,,OOPT,NO oncals,,,,,,A/L,2b course,2b course,Mat leave,,,,,,,2b course,2b course,A/L,,,,,
|
|
||||||
10/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
|
|
||||||
11/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
|
|
||||||
12/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
|
|
||||||
13/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
|
||||||
14/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
|
||||||
15/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
|
||||||
16/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
|
||||||
17/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,A/L,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
18/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
19/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
20/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
21/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
22/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
23/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
24/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
25/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
26/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
27/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
28/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
29/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
30/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
|
||||||
1/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
|
||||||
2/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
|
||||||
3/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
|
||||||
4/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
|
||||||
5/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
|
||||||
6/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
|
||||||
7/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
|
||||||
8/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,A/L,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
|
||||||
9/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
10/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
11/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
12/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
13/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
14/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
15/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
16/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
17/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
18/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
19/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
20/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
21/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
|
||||||
22/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,No nights,,,,,,A/L,,,,,,,,
|
|
||||||
23/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,A/L,A/L,,,,,,,,,A/L,,,,,,,,
|
|
||||||
24/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,A/L,A/L,,,,,,,,,A/L,,,,,,,,
|
|
||||||
25/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
|
||||||
26/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
|
||||||
27/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
|
||||||
28/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
|
||||||
29/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
|
||||||
30/10/21,,,NO ONCALL,,,A/L,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,A/L,,,,A/L,,,,,,,,,,,,,,,,,
|
|
||||||
31/10/21,,,NO ONCALL,,,A/L,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,A/L,,,,A/L,,,,,,,,,,,,,,,,,
|
|
||||||
1/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
|
||||||
2/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
|
||||||
3/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
|
||||||
4/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
|
||||||
5/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
|
||||||
6/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
|
||||||
7/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
|
||||||
8/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
|
||||||
9/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
|
||||||
10/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
|
||||||
11/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
|
||||||
12/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
|
||||||
13/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
|
||||||
14/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
|
||||||
15/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
|
||||||
16/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
|
||||||
17/11/21,2A,,NO ONCALL,,2A,2A,Mat leave,2A,,2A,,,,,,,2A,2A,2A,2A,2A,No Oncall,2A,2A,,Mat leave,,,,OOPT,,A/L,2A,,,2A,,2A,,,,,,2A,,2A,2A,,,,,2A,,2A
|
|
||||||
18/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
|
||||||
19/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
|
||||||
20/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
21/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
22/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
23/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
24/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
25/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
26/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
27/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
28/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
29/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
30/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
1/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
2/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
3/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
4/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
5/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
6/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
7/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
8/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
9/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,A/L,,,,
|
|
||||||
10/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,A/L,,,,
|
|
||||||
11/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,
|
|
||||||
12/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,
|
|
||||||
13/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
14/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
15/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
16/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
17/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
18/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
|
||||||
19/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
|
||||||
20/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
|
||||||
21/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
|
||||||
22/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
|
||||||
23/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
|
||||||
24/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
|
||||||
25/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
|
||||||
26/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
|
||||||
27/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
|
||||||
28/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
|
||||||
29/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
|
||||||
30/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
|
||||||
31/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
|
||||||
1/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
|
||||||
2/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
|
||||||
3/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
|
||||||
4/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
|
||||||
5/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
|
|
||||||
6/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
|
|
||||||
7/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
|
|
||||||
8/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,A/L
|
|
||||||
9/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,A/L
|
|
||||||
10/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
11/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
12/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
13/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
14/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
15/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
16/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
17/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
18/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
19/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
20/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
21/1/22,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
|
|
||||||
22/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
|
|
||||||
23/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
|
|
||||||
24/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
25/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
26/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
27/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
28/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
29/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
30/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
31/1/22,,,NO ONCALL,,,,Mat leave,,,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
1/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
2/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
3/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
4/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
|
||||||
5/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,,A/L,,,,
|
|
||||||
6/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,,A/L,,,,
|
|
||||||
7/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
|
||||||
8/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
|
||||||
9/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
|
||||||
10/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
|
||||||
11/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
|
||||||
12/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
|
||||||
13/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
|
||||||
14/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
15/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
16/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
17/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
18/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,A/L,,A/L,,A/L,,,,,,,,
|
|
||||||
19/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,A/L,,A/L,,A/L,,,,,,,,
|
|
||||||
20/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,A/L,,A/L,,A/L,,,,,,,,
|
|
||||||
21/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
|
||||||
22/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
|
||||||
23/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
|
||||||
24/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
|
||||||
25/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
|
|
||||||
26/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
|
|
||||||
27/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
|
|
||||||
28/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
1/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,,,,,,,,,,,,,,,A/L,A/L,,,,,
|
|
||||||
2/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,,,,,,,,,A/L,A/L,,,,,
|
|
||||||
3/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,A/L,,,,,,,,A/L,A/L,,,,,
|
|
||||||
4/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,,,,,,,,,A/L,A/L,,,,,
|
|
||||||
5/3/22,,,NO ONCALL,,,,Mat leave,,,,OOPT,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,IR oncall,,OOPC,,,,,,A/L,,,,,,,,,A/L,A/L,A/L,,,,
|
|
||||||
6/3/22,,,NO ONCALL,,,,Mat leave,,,,OOPT,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,IR oncall,,OOPC,,,,,,A/L,,,,,,,,,A/L,A/L,A/L,,,,
|
|
||||||
7/3/22,,OFF ONCALL,IDT,POST ONCALL,POST ONCALL,,OOPT,,,,OOPT,,,,,,,POST ONCALL,,,,No Oncall,,POST ONCALL,,Mat leave,,POST ONCALL,OOPT,,,,OOPC,,,POST ONCALL,POST ONCALL,,A/L,,,,,A/L,POST ONCALL,,,,,A/L,,,,
|
|
||||||
8/3/22,,OFF ONCALL,IDT,POST ONCALL,POST ONCALL,,OOPT,,,,OOPT,,,,,,,POST ONCALL,,,,No Oncall,,POST ONCALL,,Mat leave,,POST ONCALL,OOPT,,,,OOPC,,,POST ONCALL,POST ONCALL,,A/L,,,,,A/L,POST ONCALL,,,,,A/L,,,,
|
|
||||||
9/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,POST ONCALL,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
|
|
||||||
10/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
|
|
||||||
11/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
|
|
||||||
12/3/22,,OFF ONCALL,IDT,POST ONCALL,,,OOPT,,,,OOPT,A/L,,,,,,POST ONCALL,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,POST ONCALL,,A/L,,,,,A/L,,A/L,,,,A/L,,,,
|
|
||||||
13/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,A/L,,,,,A/L,,A/L,,,,A/L,,,,
|
|
||||||
14/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
|
||||||
15/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
|
||||||
16/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,POST ONCALL,A/L,,A/L,,pre 2b,A/L,,,,,
|
|
||||||
17/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,A/L,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
|
||||||
18/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
|
||||||
19/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,IR ONCALL,,,A/L,,A/L,,pre 2b,,A/L,,,,
|
|
||||||
20/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,IR ONCALL,,,A/L,,A/L,,pre 2b,,A/L,,,,
|
|
||||||
21/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,A/L,,,,pre 2b,,A/L,,,,
|
|
||||||
22/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
|
|
||||||
23/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
|
|
||||||
24/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
|
|
||||||
25/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
|
||||||
26/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
|
||||||
27/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
|
||||||
28/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,,,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,,,,,
|
|
||||||
29/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,,,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
|
||||||
30/3/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
|
||||||
31/3/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
|
||||||
1/4/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,2b,,,Ramadan,A/L,,
|
|
||||||
2/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,A/L,,,,,,,,,,,Ramadan,A/L,,
|
|
||||||
3/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,A/L,,,,,,,,,,,Ramadan,A/L,,
|
|
||||||
4/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
|
||||||
5/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
|
||||||
6/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
|
||||||
7/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,S/L,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
|
||||||
8/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
|
|
||||||
9/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
|
|
||||||
10/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
|
|
||||||
11/4/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,,,
|
|
||||||
12/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,A/L,A/L,,,A/L,,,,,,A/L,,,,,Ramadan,,,
|
|
||||||
13/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,A/L,,,,,,,,,,,Ramadan,,,
|
|
||||||
14/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,,,
|
|
||||||
15/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights or twiligts,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
|
||||||
16/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
|
||||||
17/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
|
||||||
18/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,A/L,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
|
||||||
19/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,A/L,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
20/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
21/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
22/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
23/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
24/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
25/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
26/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
27/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,S/L,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
28/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,S/L,,No nights,,,,,,,A/L,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
29/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,No nights,,,,,,,A/L,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
30/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
|
|
||||||
1/5/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
|
|
||||||
2/5/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
|
|
||||||
3/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
4/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
5/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
6/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
|
|
||||||
7/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,,,Ramadan,,,
|
|
||||||
8/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,,,Ramadan,,,
|
|
||||||
9/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
|
||||||
10/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
|
||||||
11/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
|
||||||
12/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
|
||||||
13/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
|
||||||
14/5/22,,OFF ONCALL,IDT,,,,OOPT,A/L,A/L,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,A/L,A/L,,,,,,,,,,,,,,A/L,,,,,,,
|
|
||||||
15/5/22,,OFF ONCALL,IDT,,,,OOPT,A/L,A/L,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,A/L,A/L,,,,,,,,,,,,,,A/L,,,,,,,
|
|
||||||
16/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
17/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
18/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
19/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
20/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
21/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
22/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
23/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
|
||||||
24/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
|
||||||
25/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
|
||||||
26/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
|
||||||
27/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
|
||||||
28/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,A/L,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,A/L,,
|
|
||||||
29/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,A/L,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,A/L,,
|
|
||||||
30/5/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
31/5/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
1/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
2/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
3/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
4/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
5/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
6/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,A/L,,,,Pre 2a,,Pre 2a
|
|
||||||
7/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,A/L,,,,Pre 2a,,Pre 2a
|
|
||||||
8/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
9/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,A/L,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
10/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,A/L,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
11/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,A/L,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
12/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,A/L,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
13/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
14/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
|
||||||
15/6/22,2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,2a,2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,2a,,,,,,2a,,2a
|
|
||||||
16/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,A/L,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
17/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,,2b,,,A/L,,,A/L,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
18/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,A/L,,,A/L,A/L,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
19/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,A/L,,,A/L,A/L,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
20/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
21/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,A/L,,,,,
|
|
||||||
22/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
23/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
24/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
25/6/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
26/6/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
|
||||||
27/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
28/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,A/L,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
29/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,A/L,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
30/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
1/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
|
||||||
2/7/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
|
||||||
3/7/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
|
||||||
4/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
|
|
||||||
5/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
|
|
||||||
6/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
|
|
||||||
7/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
8/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
9/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
10/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
11/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
12/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
13/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
14/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
15/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
|
|
||||||
16/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
|
|
||||||
17/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
|
|
||||||
18/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
|
||||||
19/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
|
||||||
20/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
|
||||||
21/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
|
||||||
22/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
|
||||||
23/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,A/L,,A/L,,,,A/L,,,,,,,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
|
||||||
24/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,A/L,,A/L,,,,A/L,,,,,,,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
|
||||||
25/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
|
||||||
26/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
|
||||||
27/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
|
||||||
28/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
|
||||||
29/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,,A/L,,,A/L,,OOPT,,,,
|
|
||||||
30/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,A/L,A/L,,,A/L,,OOPT,,,,
|
|
||||||
31/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,A/L,A/L,,,A/L,,OOPT,,,,
|
|
||||||
1/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
2/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
3/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
4/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
5/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
|
|
||||||
6/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
|
|
||||||
7/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
|
|
||||||
8/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,A/L,,
|
|
||||||
9/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,A/L,,
|
|
||||||
10/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
|
|
||||||
11/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
|
|
||||||
12/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
|
|
||||||
13/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,A/L,,
|
|
||||||
14/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,A/L,,
|
|
||||||
15/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
|
||||||
16/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
|
||||||
17/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
|
||||||
18/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
|
||||||
19/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
|
||||||
20/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
|
||||||
21/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
|
||||||
22/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
23/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
24/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
25/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
26/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
27/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
28/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
29/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
30/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
|
||||||
31/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
|
||||||
1/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
2/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
|
||||||
3/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,,OOPE,OOPT,,,,
|
|
||||||
4/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,OFF,,,,,,,,,,,No Oncall,,A/L,,,OFF,OFF,OOPT,OFF,,,,,,,A/L,,,,OFF,,,,,,,,OOPE,OOPT,OFF,,,
|
|
||||||
|
@@ -1,41 +1,49 @@
|
|||||||
import csv
|
import csv
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
from requests import Session
|
from requests import Session
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from rich.pretty import pprint
|
from rich.pretty import pprint
|
||||||
|
|
||||||
|
|
||||||
from rota_generator.workers import Worker, NotAvailableToWork, NonWorkingDays, WorkRequests, PreferenceNotToWork, OutOfProgramme
|
from rota_generator.workers import (
|
||||||
|
Worker,
|
||||||
|
NotAvailableToWork,
|
||||||
|
NonWorkingDays,
|
||||||
|
WorkRequests,
|
||||||
|
PreferenceNotToWork,
|
||||||
|
OutOfProgramme,
|
||||||
|
)
|
||||||
|
|
||||||
date_re = r"[\d]{1,2}\/[\d]{1,2}\/[\d]{2}"
|
date_re = r"[\d]{1,2}\/[\d]{1,2}\/[\d]{2}"
|
||||||
|
|
||||||
|
|
||||||
live_rota = True
|
live_rota = True
|
||||||
|
|
||||||
def load_leave(Rota):
|
|
||||||
|
|
||||||
|
def load_leave(Rota):
|
||||||
with Session() as s:
|
with Session() as s:
|
||||||
shifts = Rota.get_shift_names()
|
shifts = Rota.get_shift_names()
|
||||||
|
|
||||||
workers = {}
|
workers = {}
|
||||||
|
|
||||||
headers = {
|
headers = {"Cache-Control": "no-cache", "Pragma": "no-cache"}
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Pragma": "no-cache"
|
|
||||||
}
|
|
||||||
|
|
||||||
if live_rota:
|
if live_rota:
|
||||||
download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vSfcQi5Qs__A8hE2CVvUA_6ULNJALrtZOBHPUZ3xyrxPif9obtQF2IqhioT_4nebjHV1Ac5iqhNtuq4/pub?gid=2024511103&single=true&output=csv", headers=headers)
|
download = s.get(
|
||||||
#download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vSRx9VWXSlRubPyA0RhiI-Oqf5eHNYYEc6rFzlraDbR5_8qqr5g13-4uV-gn4u-TjZxiSMv1fBUaESq/pub?gid=814517272&single=true&output=csv", headers=headers)
|
"https://docs.google.com/spreadsheets/d/e/2PACX-1vSh64r9uUbPsCdhcV7zmZz10gVqIy6rhXFUvDgJxRD5W45IYEaZpKMxAqDsD1ph6dob4AiRzJXVVtOu/pub?gid=396859373&single=true&output=csv",
|
||||||
decoded_content = download.content.decode('utf-8')
|
headers=headers,
|
||||||
|
)
|
||||||
|
# download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vSRx9VWXSlRubPyA0RhiI-Oqf5eHNYYEc6rFzlraDbR5_8qqr5g13-4uV-gn4u-TjZxiSMv1fBUaESq/pub?gid=814517272&single=true&output=csv", headers=headers)
|
||||||
|
decoded_content = download.content.decode("utf-8")
|
||||||
|
|
||||||
reader = csv.reader(decoded_content.splitlines(), delimiter=',')
|
reader = csv.reader(decoded_content.splitlines(), delimiter=",")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
with open("/home/ross/Downloads/PROC Draft RK version.csv") as f:
|
with open("/home/ross/Downloads/PROC Draft RK version.csv") as f:
|
||||||
reader = csv.reader(f.read().splitlines(), delimiter=',')
|
reader = csv.reader(f.read().splitlines(), delimiter=",")
|
||||||
|
|
||||||
n = 0
|
n = 0
|
||||||
leave = {}
|
leave = {}
|
||||||
@@ -47,10 +55,12 @@ def load_leave(Rota):
|
|||||||
n = 1
|
n = 1
|
||||||
for row in reader:
|
for row in reader:
|
||||||
if n < 10:
|
if n < 10:
|
||||||
print(row)
|
pass
|
||||||
row_title = row[1]
|
#print(row)
|
||||||
print(row_title)
|
row_title = row[0]
|
||||||
|
#print(row_title)
|
||||||
row_date = row[0]
|
row_date = row[0]
|
||||||
|
row_extra = row[1]
|
||||||
r = row[2:]
|
r = row[2:]
|
||||||
# header, extract names
|
# header, extract names
|
||||||
if n == 1:
|
if n == 1:
|
||||||
@@ -80,44 +90,81 @@ def load_leave(Rota):
|
|||||||
|
|
||||||
worker = workers[a]
|
worker = workers[a]
|
||||||
|
|
||||||
lower_item = r[i].lower()
|
lower_item = r[i].lower().strip()
|
||||||
if lower_item == "derriford":
|
if lower_item == "derriford":
|
||||||
lower_item = "plymouth"
|
lower_item = "plymouth"
|
||||||
|
|
||||||
if lower_item == "derriford twilights":
|
if lower_item == "derriford twilights":
|
||||||
lower_item = "plymouth_twilights"
|
lower_item = "plymouth_twilights"
|
||||||
|
|
||||||
if "PROC site" in row_title or row_title == "PROC nights from" or "proc nights" in row_title.lower():
|
if (
|
||||||
|
"PROC site" in row_title
|
||||||
|
or row_title == "PROC nights from"
|
||||||
|
or "proc nights" in row_title.lower()
|
||||||
|
):
|
||||||
worker["site_pref"] = lower_item
|
worker["site_pref"] = lower_item
|
||||||
|
|
||||||
elif row_title in ("Rotation", "Site", "Placement", "Placement location"):
|
elif row_title in (
|
||||||
|
"Rotation",
|
||||||
|
"Site",
|
||||||
|
"Placement",
|
||||||
|
"Placement location",
|
||||||
|
"Placement location",
|
||||||
|
):
|
||||||
worker["site"] = lower_item
|
worker["site"] = lower_item
|
||||||
|
|
||||||
elif row_title in ("Grade", "Grade (ST)") or "Year of training" in row_title:
|
elif (
|
||||||
|
row_title in ("Grade", "Grade (ST)")
|
||||||
|
or "Year of training" in row_title
|
||||||
|
):
|
||||||
worker["grade"] = lower_item.lstrip("ST")
|
worker["grade"] = lower_item.lstrip("ST")
|
||||||
|
|
||||||
elif row_title in ("%FTE", "%FTE on-call", "FTE", "FTE on-call") or "on-call commitment" in row_title:
|
elif (
|
||||||
if lower_item.endswith("%"):
|
row_title
|
||||||
lower_item = float(lower_item.rstrip("%")) / 100
|
in (
|
||||||
worker["fte"] = lower_item
|
"%FTE",
|
||||||
|
"%FTE on-call",
|
||||||
|
"FTE",
|
||||||
|
"FTE on-call",
|
||||||
|
"Full-time equivalent (FTE)",
|
||||||
|
)
|
||||||
|
or "on-call commitment" in row_title
|
||||||
|
):
|
||||||
|
if lower_item:
|
||||||
|
if lower_item.endswith("%"):
|
||||||
|
lower_item = float(lower_item.rstrip("%")) / 100
|
||||||
|
worker["fte"] = lower_item
|
||||||
|
|
||||||
elif "NWD" in row_title:
|
elif (
|
||||||
|
"NWD" in row_title or row_title == "If <100% FTE, specify days off:"
|
||||||
|
):
|
||||||
worker["nwd"] = lower_item
|
worker["nwd"] = lower_item
|
||||||
|
|
||||||
elif row_title == "Flexible NWD":
|
elif row_title == "Flexible NWD":
|
||||||
worker["flexible_nwd"] = lower_item
|
worker["flexible_nwd"] = lower_item
|
||||||
|
|
||||||
elif row_title in ("End Date", "CCT date"):
|
elif row_title in ("End Date", "CCT date", "CCT Date"):
|
||||||
if lower_item:
|
if lower_item:
|
||||||
try:
|
try:
|
||||||
date = datetime.strptime(lower_item, "%d/%m/%y").date()
|
date = datetime.strptime(lower_item, "%d/%m/%y").date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
date = datetime.strptime(lower_item, "%d/%m/%Y").date()
|
try:
|
||||||
|
date = datetime.strptime(lower_item, "%d-%m-%y").date()
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
date = datetime.strptime(
|
||||||
|
lower_item, "%d/%m/%Y"
|
||||||
|
).date()
|
||||||
|
except ValueError:
|
||||||
|
print(
|
||||||
|
f"Cannot parse date: '{lower_item}' (n: {n}, row: {row})"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
worker["end_date"] = date
|
worker["end_date"] = date
|
||||||
else:
|
else:
|
||||||
worker["end_date"] = None
|
worker["end_date"] = None
|
||||||
|
|
||||||
|
|
||||||
elif row_title in ("Start date",):
|
elif row_title in ("Start date",):
|
||||||
if lower_item:
|
if lower_item:
|
||||||
try:
|
try:
|
||||||
@@ -125,13 +172,22 @@ def load_leave(Rota):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
date = datetime.strptime(lower_item, "%d/%m/%Y").date()
|
date = datetime.strptime(lower_item, "%d/%m/%Y").date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"Cannot parse date: {lower_item} (n: {n}, row: {row})")
|
logger.warning(
|
||||||
|
f"Cannot parse date: {lower_item} (n: {n}, row: {row})"
|
||||||
|
)
|
||||||
raise ValueError(f"Cannot parse date: {lower_item}")
|
raise ValueError(f"Cannot parse date: {lower_item}")
|
||||||
worker["start_date"] = date
|
worker["start_date"] = date
|
||||||
else:
|
else:
|
||||||
worker["start_date"] = None
|
worker["start_date"] = None
|
||||||
|
|
||||||
elif "OOP" in row_title or "out of programme" in row_title.lower():
|
elif "OOP" in row_title or "out of programme" in row_title.lower():
|
||||||
|
if lower_item in (
|
||||||
|
"OOPT 1 START",
|
||||||
|
"OOPT 1 END",
|
||||||
|
"OOPT 2 START",
|
||||||
|
"OOPT 2 END",
|
||||||
|
):
|
||||||
|
continue
|
||||||
worker["oop"] = lower_item
|
worker["oop"] = lower_item
|
||||||
|
|
||||||
elif row_title == "Group":
|
elif row_title == "Group":
|
||||||
@@ -156,41 +212,183 @@ def load_leave(Rota):
|
|||||||
if lower_item:
|
if lower_item:
|
||||||
worker["bank_holiday_extra"] = int(lower_item)
|
worker["bank_holiday_extra"] = int(lower_item)
|
||||||
|
|
||||||
elif re.match(date_re, row_date) is not None:
|
elif row_title == "Weekends/Twilights":
|
||||||
|
if lower_item:
|
||||||
|
worker["site"] = lower_item
|
||||||
|
|
||||||
|
|
||||||
|
#elif row_title == "Academy":
|
||||||
|
# pass
|
||||||
|
#elif "night specific" in row_title:
|
||||||
|
# if lower_item:
|
||||||
|
# for shift in lower_item.split(","):
|
||||||
|
# shift = shift.strip()
|
||||||
|
# if shift == "nights":
|
||||||
|
# shift = "night_weekday"
|
||||||
|
# elif shift == "nights only":
|
||||||
|
# if date.weekday() < 5:
|
||||||
|
# shift = "night_weekday"
|
||||||
|
# else:
|
||||||
|
# shift = "night_weekend"
|
||||||
|
# worker["single_nights"].append(
|
||||||
|
# WorkRequests(date=date, shift=shift)
|
||||||
|
# )
|
||||||
|
|
||||||
|
|
||||||
|
elif re.match(date_re, row_date) is not None:
|
||||||
if lower_item != "":
|
if lower_item != "":
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
date = datetime.strptime(row_date, "%d/%m/%y").date()
|
date = datetime.strptime(row_date, "%d/%m/%y").date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
date = datetime.strptime(row_date, "%d/%m/%Y").date()
|
date = datetime.strptime(row_date, "%d/%m/%Y").date()
|
||||||
|
|
||||||
# This may be easier to do as a dict
|
# This may be easier to do as a dict
|
||||||
if lower_item in shifts:
|
if lower_item in shifts:
|
||||||
worker["requests"].append(WorkRequests(date=date, shift=lower_item))
|
worker["requests"].append(
|
||||||
|
WorkRequests(date=date, shift=lower_item)
|
||||||
|
)
|
||||||
elif lower_item in ("nights only"):
|
elif lower_item in ("nights only"):
|
||||||
if date.weekday() < 5:
|
if date.weekday() < 5:
|
||||||
worker["requests"].append(WorkRequests(date=date, shift="night_weekday"))
|
worker["requests"].append(
|
||||||
|
WorkRequests(date=date, shift="night_weekday")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
worker["requests"].append(WorkRequests(date=date, shift="night_weekend"))
|
worker["requests"].append(
|
||||||
|
WorkRequests(date=date, shift="night_weekend")
|
||||||
elif lower_item in ("work_request", "happy to work", "offered", "may be", "volunteered"):
|
)
|
||||||
worker["requests"].append(WorkRequests(date=date, shift="*"))
|
|
||||||
|
|
||||||
|
elif lower_item in (
|
||||||
|
"work_request",
|
||||||
|
"happy to work",
|
||||||
|
"offered",
|
||||||
|
"may be",
|
||||||
|
"volunteered",
|
||||||
|
):
|
||||||
|
worker["requests"].append(
|
||||||
|
WorkRequests(date=date, shift="*")
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
worker["leave"].append(NotAvailableToWork(date=date, reason=lower_item))
|
worker["leave"].append(
|
||||||
|
NotAvailableToWork(date=date, reason=lower_item)
|
||||||
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
print(f"Error with: {lower_item}")
|
print(f"Error with: {lower_item}")
|
||||||
print(f"{row=}")
|
print(f"{row=}")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
n = n + 1
|
n = n + 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return workers
|
return workers
|
||||||
|
|
||||||
|
|
||||||
# load_leave()
|
def load_academy(Rota):
|
||||||
|
"""Load academy month blocks from the main published CSV and register avoids.
|
||||||
|
|
||||||
|
Spreadsheet layout expected:
|
||||||
|
- Column A: row title (should be 'Academy' for relevant rows)
|
||||||
|
- Column B: start date for the month (e.g. '07/09/2026')
|
||||||
|
- Columns C...: worker columns matching the header row (first CSV row's columns C...)
|
||||||
|
Cells containing the word 'academy' (case-insensitive) mark that worker as being in academy
|
||||||
|
for that month.
|
||||||
|
"""
|
||||||
|
# Use the same published CSV as load_leave (main sheet)
|
||||||
|
url = (
|
||||||
|
"https://docs.google.com/spreadsheets/d/e/2PACX-1vSh64r9uUbPsCdhcV7zmZz10gVqIy6rhXFUvDgJxRD5W45IYEaZpKMxAqDsD1ph6dob4AiRzJXVVtOu/pub?gid=396859373&single=true&output=csv"
|
||||||
|
)
|
||||||
|
|
||||||
|
with Session() as s:
|
||||||
|
headers = {"Cache-Control": "no-cache", "Pragma": "no-cache"}
|
||||||
|
download = s.get(url, headers=headers)
|
||||||
|
decoded = download.content.decode("utf-8")
|
||||||
|
|
||||||
|
reader = csv.reader(decoded.splitlines(), delimiter=",")
|
||||||
|
|
||||||
|
names = []
|
||||||
|
month_starts: list[datetime.date] = []
|
||||||
|
month_cells: list[list[str]] = []
|
||||||
|
|
||||||
|
n = 0
|
||||||
|
for row in reader:
|
||||||
|
n += 1
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# header row: collect worker names from column C onwards
|
||||||
|
if n == 1:
|
||||||
|
# some files may have two leading columns (like 'Email Address' and blank/extra), so take from index 2
|
||||||
|
names = [c.strip() for c in (row[2:] if len(row) > 2 else row[1:])]
|
||||||
|
continue
|
||||||
|
|
||||||
|
row_title = row[0].strip() if len(row) > 0 else ""
|
||||||
|
start_cell = row[1].strip() if len(row) > 1 else ""
|
||||||
|
cells = row[2:] if len(row) > 2 else []
|
||||||
|
|
||||||
|
# Only treat rows where the first column mentions 'Academy' (some sheets may include other rows)
|
||||||
|
if row_title and "academy" in row_title.lower():
|
||||||
|
# parse start date
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(start_cell, "%d/%m/%Y").date()
|
||||||
|
except ValueError:
|
||||||
|
start_date = datetime.strptime(start_cell, "%d/%m/%y").date()
|
||||||
|
except Exception:
|
||||||
|
# skip rows without a valid date
|
||||||
|
continue
|
||||||
|
|
||||||
|
month_starts.append(start_date)
|
||||||
|
# normalize cell list to names length
|
||||||
|
row_cells = [(cells[i] if i < len(cells) else "").strip() for i in range(len(names))]
|
||||||
|
month_cells.append(row_cells)
|
||||||
|
|
||||||
|
if not month_starts:
|
||||||
|
return
|
||||||
|
|
||||||
|
# determine twilight shifts available
|
||||||
|
twilight_shift_names = [s.name for s in Rota.get_shifts() if "twilight" in s.name]
|
||||||
|
|
||||||
|
# for each month start, compute end date and register avoids
|
||||||
|
for idx, start_date in enumerate(month_starts):
|
||||||
|
if idx + 1 < len(month_starts):
|
||||||
|
end_date = month_starts[idx + 1] - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
end_date = Rota.rota_end_date
|
||||||
|
|
||||||
|
cells = month_cells[idx]
|
||||||
|
for i, cell in enumerate(cells):
|
||||||
|
if i >= len(names):
|
||||||
|
break
|
||||||
|
v = (cell or "").lower()
|
||||||
|
if "academy" in v:
|
||||||
|
worker_name = names[i]
|
||||||
|
matching_workers = [w for w in Rota.workers if w.name.strip() == worker_name.strip()]
|
||||||
|
if not matching_workers:
|
||||||
|
logger.warning(f"Academy data: worker '{worker_name}' not found in rota, skipping")
|
||||||
|
continue
|
||||||
|
worker = matching_workers[0]
|
||||||
|
|
||||||
|
# choose twilight shifts relevant to the worker's site
|
||||||
|
candidate_shifts = []
|
||||||
|
for sh_name in twilight_shift_names:
|
||||||
|
try:
|
||||||
|
sh = Rota.get_shift_by_name(sh_name)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if hasattr(worker, "site") and worker.site in getattr(sh, "sites", []):
|
||||||
|
candidate_shifts.append(sh_name)
|
||||||
|
elif sh_name.startswith(f"{worker.site}_"):
|
||||||
|
candidate_shifts.append(sh_name)
|
||||||
|
|
||||||
|
if not candidate_shifts:
|
||||||
|
candidate_shifts = twilight_shift_names.copy()
|
||||||
|
|
||||||
|
#print(f"Academy: {worker.name} on academy from {start_date} to {end_date}, avoiding shifts: {candidate_shifts}")
|
||||||
|
|
||||||
|
Rota.add_avoid_shifts_for_workers_on_dates(
|
||||||
|
names=[worker.name],
|
||||||
|
shifts=candidate_shifts,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
reason="academy",
|
||||||
|
)
|
||||||
@@ -445,9 +445,19 @@ $("#rota-table tr td:first-child").each((n, td) => {
|
|||||||
|
|
||||||
function viewWorker(worker) {
|
function viewWorker(worker) {
|
||||||
ds = worker.dataset;
|
ds = worker.dataset;
|
||||||
|
let exactShiftsText = "(none)";
|
||||||
|
if (ds.exactShifts) {
|
||||||
|
try {
|
||||||
|
let parsed = JSON.parse(ds.exactShifts);
|
||||||
|
if (Object.keys(parsed).length > 0) {
|
||||||
|
exactShiftsText = Object.entries(parsed).map(([s, c]) => `${s}: ${c}`).join(", ");
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
dlg = $(`<div class='dialog' title='${ds.worker}'>
|
dlg = $(`<div class='dialog' title='${ds.worker}'>
|
||||||
Site: ${ds.site}</br>
|
Site: ${ds.site}</br>
|
||||||
FTE: ${ds.fte} (${ds.fte_adj})</br>
|
FTE: ${ds.fte} (${ds.fte_adj})</br>
|
||||||
|
Exact shifts: ${exactShiftsText}</br>
|
||||||
Start date: ${ds.start_date}</br>
|
Start date: ${ds.start_date}</br>
|
||||||
End date: ${ds.end_date}</br>
|
End date: ${ds.end_date}</br>
|
||||||
Non working days: ${ds.nwds}</br>
|
Non working days: ${ds.nwds}</br>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
/home/ross/proc-rota/output/timetable.css
|
/home/ross/proc_rota/output/timetable.css
|
||||||
@@ -1 +1 @@
|
|||||||
/home/ross/proc-rota/output/timetable.js
|
/home/ross/proc_rota/output/timetable.js
|
||||||
+729
-8
@@ -78,11 +78,11 @@ function generateExtra() {
|
|||||||
|
|
||||||
shift_counts = {};
|
shift_counts = {};
|
||||||
|
|
||||||
total_shifts = 0;
|
//total_shifts = 0;
|
||||||
days_lost = 0;
|
days_lost = 0;
|
||||||
shifts_unique.forEach(el => {
|
shifts_unique.forEach(el => {
|
||||||
shift_counts[el] = shifts.count(el);
|
shift_counts[el] = shifts.count(el);
|
||||||
total_shifts = total_shifts + shifts.count(el);
|
//total_shifts = total_shifts + shifts.count(el);
|
||||||
|
|
||||||
// why like this?
|
// why like this?
|
||||||
if (el == "night_weekend") {
|
if (el == "night_weekend") {
|
||||||
@@ -259,6 +259,52 @@ function generateExtra() {
|
|||||||
|
|
||||||
$(table).before(summary_button);
|
$(table).before(summary_button);
|
||||||
|
|
||||||
|
// Add per-table avoid-toggle button (near the summary button)
|
||||||
|
avoid_button = $("<button class='auto-generated'>Toggle Avoids</button>").click((evt) => {
|
||||||
|
// ensure style exists once
|
||||||
|
if ($('#avoid-shift-style').length === 0) {
|
||||||
|
$('head').append(`
|
||||||
|
<style id='avoid-shift-style'>
|
||||||
|
td.rota-day.avoid-shift.avoid-highlight { border: 2px solid rgba(255,140,0,0.9) !important; box-shadow: 0 0 6px rgba(255,140,0,0.25); }
|
||||||
|
</style>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let $btn = $(evt.target);
|
||||||
|
let enabled = $btn.data('enabled') || false;
|
||||||
|
enabled = !enabled;
|
||||||
|
$btn.data('enabled', enabled);
|
||||||
|
|
||||||
|
let $table = $(table);
|
||||||
|
$table.find("td.rota-day.avoid-shift").each((i, el) => {
|
||||||
|
let $el = $(el);
|
||||||
|
if (enabled) {
|
||||||
|
$el.addClass('avoid-highlight');
|
||||||
|
// preserve original title
|
||||||
|
if (!$el.data('orig-title')) $el.data('orig-title', $el.attr('title') || '');
|
||||||
|
let avoid_shifts = $el.attr('data-avoid-shifts') || '';
|
||||||
|
let avoid_reason = $el.attr('data-avoid-reason') || '';
|
||||||
|
let suffix = [];
|
||||||
|
// make the constraint type explicit in the tooltip
|
||||||
|
suffix.push(`constraint: avoid_shifts_by_worker_dates`);
|
||||||
|
if (avoid_shifts) suffix.push(`shifts: ${avoid_shifts}`);
|
||||||
|
if (avoid_reason) suffix.push(`reason: ${avoid_reason}`);
|
||||||
|
if (suffix.length) {
|
||||||
|
let newTitle = $el.data('orig-title') + ' [' + suffix.join('; ') + ']';
|
||||||
|
$el.attr('title', newTitle);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$el.removeClass('avoid-highlight');
|
||||||
|
// restore original title
|
||||||
|
if ($el.data('orig-title') !== undefined) $el.attr('title', $el.data('orig-title'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$btn.text(enabled ? 'Hide Avoids' : 'Toggle Avoids');
|
||||||
|
});
|
||||||
|
|
||||||
|
$(table).before(avoid_button);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -281,6 +327,8 @@ $("#toggle-prior-adjust").on("click", (evt) => {
|
|||||||
$("table.tsummary").find(`td.prior-adjust, th.prior-adjust-col`).toggle();
|
$("table.tsummary").find(`td.prior-adjust, th.prior-adjust-col`).toggle();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: per-table avoid-toggle buttons are added next to each table's summary button inside generateExtra().
|
||||||
|
|
||||||
$("table.tsummary").append("<tr class='header'></tr>");
|
$("table.tsummary").append("<tr class='header'></tr>");
|
||||||
h = $("table.tsummary tr.header");
|
h = $("table.tsummary tr.header");
|
||||||
h.append(`<th>Worker</th>`);
|
h.append(`<th>Worker</th>`);
|
||||||
@@ -294,9 +342,416 @@ oshifts.forEach((s) => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Render formatted Shifts settings and Worker details with filtering
|
||||||
|
function renderShiftSettings() {
|
||||||
|
const $container = $("#shifts-container");
|
||||||
|
if (!$container.length) return;
|
||||||
|
let shiftsJson = $container.data("shifts-json");
|
||||||
|
try {
|
||||||
|
if (typeof shiftsJson === 'string') shiftsJson = JSON.parse(shiftsJson);
|
||||||
|
} catch (e) {
|
||||||
|
// fallback: no structured data
|
||||||
|
shiftsJson = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $table = $("<table class='shifts-table' style='width:100%; border-collapse:collapse; margin-bottom:8px;'></table>");
|
||||||
|
$table.append(`<tr><th style='text-align:left'>Name</th><th style='text-align:left'>Sites</th><th style='text-align:left'>Days</th><th style='text-align:left'>Balance offset</th></tr>`);
|
||||||
|
if (shiftsJson && Array.isArray(shiftsJson)) {
|
||||||
|
shiftsJson.forEach(s => {
|
||||||
|
const sites = (s.sites || []).join(", ");
|
||||||
|
const days = (s.days || []).join(", ");
|
||||||
|
$table.append(`<tr class='shift-row' data-shift-name='${s.name}'><td>${s.name}</td><td>${sites}</td><td>${days}</td><td>${s.balance_offset ?? ''}</td></tr>`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// fallback to existing content
|
||||||
|
const html = $("#shifts-table").html();
|
||||||
|
$("#shifts-table").empty().append(html);
|
||||||
|
}
|
||||||
|
$("#shifts-table").empty().append($table);
|
||||||
|
|
||||||
|
// filter input
|
||||||
|
$("#shift-filter").on("input", (e) => {
|
||||||
|
const v = $(e.target).val().toLowerCase();
|
||||||
|
$(".shift-row").each((i, el) => {
|
||||||
|
const name = $(el).data('shift-name') + '';
|
||||||
|
$(el).toggle(name.toLowerCase().indexOf(v) !== -1);
|
||||||
|
});
|
||||||
|
// update shift select in worker panel if present
|
||||||
|
renderWorkerFilterOptions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkerFilterOptions() {
|
||||||
|
const $wd = $("#worker_details");
|
||||||
|
if (!$wd.length) return;
|
||||||
|
let workers = $wd.data("workers");
|
||||||
|
try { if (typeof workers === 'string') workers = JSON.parse(workers); } catch (e) { workers = null; }
|
||||||
|
// populate shift select with shifts present in current shifts table
|
||||||
|
const shiftNames = [];
|
||||||
|
$(".shift-row:visible").each((i, el) => shiftNames.push($(el).data('shift-name')));
|
||||||
|
const $select = $("#worker-shift-select");
|
||||||
|
if ($select.length === 0) {
|
||||||
|
$("#worker_details").prepend('<div style="margin-bottom:8px;"><input type="text" id="worker-filter" placeholder="Filter workers by name/site" style="margin-right:8px;"/> <select id="worker-shift-select"><option value="">(All shifts)</option></select></div>');
|
||||||
|
}
|
||||||
|
const $sel = $("#worker-shift-select");
|
||||||
|
$sel.empty().append('<option value="">(All shifts)</option>');
|
||||||
|
shiftNames.forEach(s => $sel.append(`<option value="${s}">${s}</option>`));
|
||||||
|
|
||||||
|
// attach events
|
||||||
|
$("#worker-filter").off('input').on('input', () => renderWorkerList());
|
||||||
|
$sel.off('change').on('change', () => renderWorkerList());
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkerList() {
|
||||||
|
const $wd = $("#worker_details");
|
||||||
|
if (!$wd.length) return;
|
||||||
|
let workers = $wd.data("workers");
|
||||||
|
try { if (typeof workers === 'string') workers = JSON.parse(workers); } catch (e) { workers = null; }
|
||||||
|
const filter = ($("#worker-filter").val() || '').toLowerCase();
|
||||||
|
const shiftFilter = ($("#worker-shift-select").val() || '');
|
||||||
|
|
||||||
|
// Parse the original pre block into per-worker text blocks so rich details are preserved.
|
||||||
|
const detailsByName = {};
|
||||||
|
const preText = (($wd.find("pre").first().text() || '') + '').trim();
|
||||||
|
const escapeHtml = (text) => String(text)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
if (preText.length > 0) {
|
||||||
|
preText.split(/\n\s*\n(?=Name:\s*)/g).forEach(block => {
|
||||||
|
const trimmed = (block || '').trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const nameMatch = trimmed.match(/^Name:\s*(.+)$/m);
|
||||||
|
if (!nameMatch) return;
|
||||||
|
detailsByName[nameMatch[1].trim()] = trimmed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let $list = $("#worker-list");
|
||||||
|
if ($list.length === 0) {
|
||||||
|
$list = $("<div id='worker-list' style='margin-top:8px;'></div>");
|
||||||
|
$("#worker_details").append($list);
|
||||||
|
}
|
||||||
|
// Hide the static pre block since we render interactive cards
|
||||||
|
$("#worker_details pre").hide();
|
||||||
|
$list.show();
|
||||||
|
$list.empty();
|
||||||
|
if (!workers) return;
|
||||||
|
workers.forEach(w => {
|
||||||
|
const name = (w.name || '') + '';
|
||||||
|
const site = (w.site || '') + '';
|
||||||
|
if (filter.length > 0) {
|
||||||
|
const matchesName = name.toLowerCase().indexOf(filter) !== -1;
|
||||||
|
const matchesSite = site.toLowerCase().indexOf(filter) !== -1;
|
||||||
|
if (!matchesName && !matchesSite) return;
|
||||||
|
}
|
||||||
|
// if shiftFilter, ensure worker has target or assigned for that shift
|
||||||
|
// attempt to read per-worker targets and counts from DOM if not present in JSON
|
||||||
|
let targets = w.shift_target_number || {};
|
||||||
|
let counts = w.shift_counts || {};
|
||||||
|
const domRow = $(`td.worker[data-worker='${name}']`).first();
|
||||||
|
if (domRow && domRow.length) {
|
||||||
|
try {
|
||||||
|
const domTargets = domRow.data('worker-targets');
|
||||||
|
if (domTargets && Object.keys(targets).length === 0) targets = domTargets;
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
const domCounts = domRow.data('shift-counts');
|
||||||
|
if (domCounts && Object.keys(counts).length === 0) counts = domCounts;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (shiftFilter) {
|
||||||
|
const assigned = (counts && counts[shiftFilter]) ? counts[shiftFilter] : 0;
|
||||||
|
const hasTarget = Object.keys(targets || {}).indexOf(shiftFilter) !== -1 && (targets[shiftFilter] || 0) > 0;
|
||||||
|
const hasAssigned = assigned > 0;
|
||||||
|
if (!hasTarget && !hasAssigned) return;
|
||||||
|
}
|
||||||
|
const fte = w.fte || '';
|
||||||
|
const grade = w.grade || '';
|
||||||
|
const id = w.id ? String(w.id).substring(0, 8) : '';
|
||||||
|
const card = $(`<div class='worker-card'><div class='worker-header'><strong>${name}</strong> <span class='worker-id'>${id}</span></div><div class='worker-meta'><span class='worker-site'>${site}</span> • <span class='worker-grade'>Grade: ${grade}</span> • <span class='worker-fte'>FTE: ${fte}%</span></div></div>`);
|
||||||
|
// show shift targets summary
|
||||||
|
if (targets && Object.keys(targets).length > 0) {
|
||||||
|
const tbl = $("<table class='worker-shifts-table'></table>");
|
||||||
|
const header = $("<thead><tr><th>Shift</th><th class='text-right'>Assigned</th><th class='text-right'>Target</th></tr></thead>");
|
||||||
|
tbl.append(header);
|
||||||
|
const tbody = $("<tbody></tbody>");
|
||||||
|
Object.keys(targets).forEach(s => {
|
||||||
|
const t = targets[s];
|
||||||
|
const c = (counts && counts[s]) ? counts[s] : 0;
|
||||||
|
tbody.append(`<tr><td>${s}</td><td class='text-right'>${c}</td><td class='text-right'>${parseFloat(t).toFixed(2)}</td></tr>`);
|
||||||
|
});
|
||||||
|
tbl.append(tbody);
|
||||||
|
card.append(tbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include the original full worker detail text block so no information is lost.
|
||||||
|
const fullDetails = detailsByName[name] || '';
|
||||||
|
if (fullDetails) {
|
||||||
|
card.append(`<details class='worker-full-details'><summary>Full details</summary><pre>${escapeHtml(fullDetails)}</pre></details>`);
|
||||||
|
}
|
||||||
|
$list.append(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CSS for worker details styling and comprehensive dark mode support
|
||||||
|
if ($('#worker-details-style').length === 0) {
|
||||||
|
$('head').append(`<style id='worker-details-style'>
|
||||||
|
/* ===== Light mode defaults ===== */
|
||||||
|
body {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
#worker_details {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
#worker-filter, #worker-shift-select {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
#worker-filter:focus, #worker-shift-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0066cc;
|
||||||
|
box-shadow: 0 0 4px rgba(0,102,204,0.3);
|
||||||
|
}
|
||||||
|
.worker-card {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.worker-card:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-color: #999;
|
||||||
|
}
|
||||||
|
.worker-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.worker-header strong {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.worker-id {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #999;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.worker-meta {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.worker-site, .worker-grade, .worker-fte {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.worker-shifts-table {
|
||||||
|
margin-top: 6px;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.worker-shifts-table thead {
|
||||||
|
background: #f0f0f0;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.worker-shifts-table th, .worker-shifts-table td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.worker-shifts-table tr:nth-child(even) {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.worker-full-details {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.worker-full-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.worker-full-details pre {
|
||||||
|
margin-top: 6px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #000;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
input, select, textarea {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
table th {
|
||||||
|
background: #f9f9f9;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
table td {
|
||||||
|
border-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Dark mode overrides (toggled via html.dark-mode class) ===== */
|
||||||
|
html.dark-mode body {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode #worker_details {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode #worker-filter, html.dark-mode #worker-shift-select {
|
||||||
|
background: #3d3d3d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #555;
|
||||||
|
}
|
||||||
|
html.dark-mode #worker-filter:focus, html.dark-mode #worker-shift-select:focus {
|
||||||
|
border-color: #0088ff;
|
||||||
|
box-shadow: 0 0 4px rgba(0,136,255,0.5);
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-card {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-color: #444;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-card:hover {
|
||||||
|
background: #333;
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-header strong {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-meta, html.dark-mode .worker-id {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-shifts-table {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-shifts-table thead {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-shifts-table th, html.dark-mode .worker-shifts-table td {
|
||||||
|
border-color: #444;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-shifts-table tr:nth-child(even) {
|
||||||
|
background: #252525;
|
||||||
|
}
|
||||||
|
html.dark-mode .worker-full-details summary {
|
||||||
|
color: #cfd8e3;
|
||||||
|
}
|
||||||
|
html.dark-mode pre {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #444;
|
||||||
|
}
|
||||||
|
html.dark-mode input, html.dark-mode select, html.dark-mode textarea {
|
||||||
|
background: #3d3d3d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #555;
|
||||||
|
}
|
||||||
|
html.dark-mode table {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode table th {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode table td {
|
||||||
|
border-color: #444;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode h1, html.dark-mode h2, html.dark-mode h3, html.dark-mode h4, html.dark-mode h5, html.dark-mode h6 {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
html.dark-mode a {
|
||||||
|
color: #4db8ff;
|
||||||
|
}
|
||||||
|
html.dark-mode a:visited {
|
||||||
|
color: #8866ff;
|
||||||
|
}
|
||||||
|
html.dark-mode button {
|
||||||
|
background: #3d3d3d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #555;
|
||||||
|
}
|
||||||
|
html.dark-mode button:hover {
|
||||||
|
background: #4d4d4d;
|
||||||
|
}
|
||||||
|
</style>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize panels
|
||||||
|
renderShiftSettings();
|
||||||
|
renderWorkerFilterOptions();
|
||||||
|
renderWorkerList();
|
||||||
|
|
||||||
// Column for prior-adjustment (hidden by default)
|
// Column for prior-adjustment (hidden by default)
|
||||||
h.append(`<th class='prior-adjust-col' style='display:none'>Prior Adjust</th>`);
|
h.append(`<th class='prior-adjust-col' style='display:none'>Prior Adjust</th>`);
|
||||||
|
|
||||||
|
// Ensure header styles for visibility and stickiness are present
|
||||||
|
if ($('#tsummary-style').length === 0) {
|
||||||
|
$('head').append(`
|
||||||
|
<style id='tsummary-style'>
|
||||||
|
/* Compact timetable styles */
|
||||||
|
table.tsummary { border-collapse: collapse; width: 100%; overflow: auto; font-size: 12px; }
|
||||||
|
table.tsummary th, table.tsummary td { border: 1px solid #eee; padding: 2px 4px; }
|
||||||
|
table.tsummary th { background: #fafafa; position: sticky; top: 0; z-index: 5; white-space: nowrap; }
|
||||||
|
/* Vertical headers for compact columns */
|
||||||
|
table.tsummary th { writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg); padding: 4px 2px; height: 88px; font-size: 10px; }
|
||||||
|
/* Keep prior-adjust column horizontal */
|
||||||
|
table.tsummary th.prior-adjust-col { writing-mode: horizontal-tb; transform: none; height: auto; }
|
||||||
|
/* Make header text readable */
|
||||||
|
table.tsummary th > span { display: inline-block; transform: rotate(180deg); padding: 0 2px; }
|
||||||
|
/* Ensure cells align under rotated headers */
|
||||||
|
table.tsummary td { vertical-align: middle; white-space: nowrap; max-width: 6ch; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
/* Reduce worker summary and card sizes */
|
||||||
|
.worker-summary { font-size: 11px; padding: 0px 6px; margin-bottom: 4px; }
|
||||||
|
.worker-card { padding: 6px; margin-bottom: 4px; }
|
||||||
|
|
||||||
|
/* Compact shifts/settings table */
|
||||||
|
table.shifts-table th, table.shifts-table td { padding: 4px 6px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* Slightly thinner avoid highlight for compact layout */
|
||||||
|
td.rota-day.avoid-shift.avoid-highlight { border-width: 1.5px !important; box-shadow: 0 0 4px rgba(255,140,0,0.18); }
|
||||||
|
|
||||||
|
/* Higher-contrast dark mode for timetable readability */
|
||||||
|
html.dark-mode table.tsummary { background: #111922; color: #e8eef5; }
|
||||||
|
html.dark-mode table.tsummary th,
|
||||||
|
html.dark-mode table.tsummary td { border-color: #516274; }
|
||||||
|
html.dark-mode table.tsummary th { background: #253241; color: #f5f8fc; }
|
||||||
|
html.dark-mode table.tsummary td { background: #16212d; color: #e8eef5; }
|
||||||
|
html.dark-mode table.tsummary tr:nth-child(even) td { background: #1a2734; }
|
||||||
|
html.dark-mode table.tsummary td.target-assigned { color: #f2f6fb; }
|
||||||
|
</style>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
$(".table-div .worker-row .worker").each((n, tr) => {
|
$(".table-div .worker-row .worker").each((n, tr) => {
|
||||||
@@ -319,6 +774,16 @@ $(".table-div .worker-row .worker").each((n, tr) => {
|
|||||||
locum_shift_counts = jtr.data("locum-shift-counts")
|
locum_shift_counts = jtr.data("locum-shift-counts")
|
||||||
shift_targets = jtr.data("worker-targets")
|
shift_targets = jtr.data("worker-targets")
|
||||||
|
|
||||||
|
// Compute total shifts from shift_counts (sum of assigned counts)
|
||||||
|
let total_shifts = 0;
|
||||||
|
try {
|
||||||
|
if (shift_counts && typeof shift_counts === 'object') {
|
||||||
|
total_shifts = Object.values(shift_counts).reduce((a, b) => a + (parseFloat(b) || 0), 0);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
total_shifts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
oshifts.forEach((s) => {
|
oshifts.forEach((s) => {
|
||||||
if (s in shift_targets && shift_targets[s] > 0) {
|
if (s in shift_targets && shift_targets[s] > 0) {
|
||||||
@@ -561,6 +1026,8 @@ $("table.tsummary").find("td.prior-adjust .prior-adjust-part").each((n, span) =>
|
|||||||
let selectedShifts = new Set();
|
let selectedShifts = new Set();
|
||||||
|
|
||||||
let shiftColors = {};
|
let shiftColors = {};
|
||||||
|
let rotaExportViewEnabled = false;
|
||||||
|
let shiftDisplayOrder = [...oshifts];
|
||||||
|
|
||||||
oshifts.forEach((shift, i) => {
|
oshifts.forEach((shift, i) => {
|
||||||
selectedShifts.add(shift);
|
selectedShifts.add(shift);
|
||||||
@@ -573,6 +1040,21 @@ $("#shift-timetable-options").append(
|
|||||||
`<button id="toggle-grid-view" style="margin-left:10px;">Toggle Linear/Grid View</button><br/>`
|
`<button id="toggle-grid-view" style="margin-left:10px;">Toggle Linear/Grid View</button><br/>`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add a button to toggle rota export view (spreadsheet-style day-by-day shifts)
|
||||||
|
$("#shift-timetable-options").append(
|
||||||
|
`<button id="toggle-rota-export-view" style="margin-left:10px;">Show Rota Export View</button>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add optional control to show worker names in rota export view
|
||||||
|
$("#shift-timetable-options").append(`
|
||||||
|
<span id="rota-export-show-workers-wrap" style="margin-left:12px;">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="rota-export-show-workers" checked>
|
||||||
|
Show workers
|
||||||
|
</label>
|
||||||
|
</span><br/>
|
||||||
|
`);
|
||||||
|
|
||||||
let gridViewEnabled = false;
|
let gridViewEnabled = false;
|
||||||
|
|
||||||
$("#toggle-grid-view").on("click", function () {
|
$("#toggle-grid-view").on("click", function () {
|
||||||
@@ -580,6 +1062,22 @@ $("#toggle-grid-view").on("click", function () {
|
|||||||
renderShiftTimetable();
|
renderShiftTimetable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#toggle-rota-export-view").on("click", function () {
|
||||||
|
rotaExportViewEnabled = !rotaExportViewEnabled;
|
||||||
|
if (rotaExportViewEnabled) {
|
||||||
|
$(this).text("Show Linear/Grid View");
|
||||||
|
} else {
|
||||||
|
$(this).text("Show Rota Export View");
|
||||||
|
}
|
||||||
|
renderShiftTimetable();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("change", "#rota-export-show-workers", function () {
|
||||||
|
if (rotaExportViewEnabled) {
|
||||||
|
renderShiftTimetable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const shiftColourControls = $(`
|
const shiftColourControls = $(`
|
||||||
<details><summary>Shift Timetable Colours</summary>
|
<details><summary>Shift Timetable Colours</summary>
|
||||||
@@ -722,6 +1220,9 @@ function renderWorkerCheckboxes() {
|
|||||||
let workerFilterSet = new Set();
|
let workerFilterSet = new Set();
|
||||||
// Store the current setting
|
// Store the current setting
|
||||||
let showShiftNames = false;
|
let showShiftNames = false;
|
||||||
|
// Controls for rendering
|
||||||
|
let showWeekStart = false;
|
||||||
|
let showGrades = false;
|
||||||
|
|
||||||
// Call this after the DOM is ready and after timetable is rendered
|
// Call this after the DOM is ready and after timetable is rendered
|
||||||
renderWorkerCheckboxes();
|
renderWorkerCheckboxes();
|
||||||
@@ -739,8 +1240,20 @@ $(document).on("change", "input[name='worker-filter-checkbox']", function() {
|
|||||||
);
|
);
|
||||||
renderShiftTimetable();
|
renderShiftTimetable();
|
||||||
});
|
});
|
||||||
|
// Add a checkbox to show/hide worker grades in the shift timetable
|
||||||
|
$("#shift-timetable-options").prepend(`
|
||||||
|
<div id="shift-timetable-show-grades" style="margin-bottom:8px;">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="show-grades-checkbox">
|
||||||
|
Show worker grades in timetable
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
let showWeekStart = false;
|
$(document).on("change", "#show-grades-checkbox", function() {
|
||||||
|
showGrades = $(this).is(":checked");
|
||||||
|
renderShiftTimetable();
|
||||||
|
});
|
||||||
// Add a checkbox to show/hide shift names in the shift timetable section
|
// Add a checkbox to show/hide shift names in the shift timetable section
|
||||||
$("#shift-timetable-options").prepend(`
|
$("#shift-timetable-options").prepend(`
|
||||||
<div id="shift-timetable-show-shift-names" style="margin-bottom:8px;">
|
<div id="shift-timetable-show-shift-names" style="margin-bottom:8px;">
|
||||||
@@ -792,6 +1305,12 @@ function renderShiftTimetable() {
|
|||||||
console.log(workerFilterSet)
|
console.log(workerFilterSet)
|
||||||
$("#shift-timetable-div").empty();
|
$("#shift-timetable-div").empty();
|
||||||
if (selectedShifts.size === 0) return;
|
if (selectedShifts.size === 0) return;
|
||||||
|
|
||||||
|
if (rotaExportViewEnabled) {
|
||||||
|
renderRotaExportTimetable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (workerFilterSet.size === 0) return;
|
if (workerFilterSet.size === 0) return;
|
||||||
|
|
||||||
// Helper: returns true if worker id matches filter set or set is empty (all)
|
// Helper: returns true if worker id matches filter set or set is empty (all)
|
||||||
@@ -823,7 +1342,7 @@ function renderShiftTimetable() {
|
|||||||
row.append(`<td>${day}</td>`);
|
row.append(`<td>${day}</td>`);
|
||||||
|
|
||||||
let workerCells = [];
|
let workerCells = [];
|
||||||
$(`table#main-table td[data-date='${date}']`).filter(function () {
|
$(`table#main-table td[data-date='${date}']`).filter(function () {
|
||||||
let shifts = $(this).attr("data-shift");
|
let shifts = $(this).attr("data-shift");
|
||||||
if (!shifts) return false;
|
if (!shifts) return false;
|
||||||
let cell_shifts = shifts.split(",").map(s => s.trim());
|
let cell_shifts = shifts.split(",").map(s => s.trim());
|
||||||
@@ -831,6 +1350,14 @@ function renderShiftTimetable() {
|
|||||||
}).each((n, td) => {
|
}).each((n, td) => {
|
||||||
let workerTd = $(td).closest("tr").children("td:first");
|
let workerTd = $(td).closest("tr").children("td:first");
|
||||||
let workerName = workerTd.find("span.name").text().trim();
|
let workerName = workerTd.find("span.name").text().trim();
|
||||||
|
// grade may be provided as a data attribute; if not, try to parse from visible text
|
||||||
|
let workerGrade = workerTd.attr('data-grade') || '';
|
||||||
|
if (!workerGrade) {
|
||||||
|
// attempt to parse e.g. "Name (4) [100]" -> capture 4
|
||||||
|
let txt = workerTd.text();
|
||||||
|
let m = txt.match(/\(([^)]+)\)/);
|
||||||
|
if (m) workerGrade = m[1].trim();
|
||||||
|
}
|
||||||
let workerId = workerTd.attr("data-worker-id");
|
let workerId = workerTd.attr("data-worker-id");
|
||||||
if (!workerMatchesFilter(workerId)) return;
|
if (!workerMatchesFilter(workerId)) return;
|
||||||
let cell_shifts = $(td).attr("data-shift").split(",").map(s => s.trim());
|
let cell_shifts = $(td).attr("data-shift").split(",").map(s => s.trim());
|
||||||
@@ -844,7 +1371,8 @@ function renderShiftTimetable() {
|
|||||||
color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`;
|
color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`;
|
||||||
if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts.join(", ")})</span>`;
|
if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts.join(", ")})</span>`;
|
||||||
}
|
}
|
||||||
workerCells.push(`<td style="background:${color}">${workerName}${shiftLabel}</td>`);
|
let gradeTextLinear = showGrades ? ` <span style="font-size:0.85em;color:#444;">(${workerGrade})</span>` : '';
|
||||||
|
workerCells.push(`<td style="background:${color}">${workerName}${gradeTextLinear}${shiftLabel}</td>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (workerCells.length === 0) {
|
if (workerCells.length === 0) {
|
||||||
@@ -885,10 +1413,12 @@ function renderShiftTimetable() {
|
|||||||
$("table#main-table td.worker").each(function () {
|
$("table#main-table td.worker").each(function () {
|
||||||
let workerId = $(this).attr("data-worker-id");
|
let workerId = $(this).attr("data-worker-id");
|
||||||
let workerName = $(this).find("span.name").text().trim();
|
let workerName = $(this).find("span.name").text().trim();
|
||||||
|
let workerGrade = $(this).attr('data-grade') || '';
|
||||||
if (workerId && !workerIds.includes(workerId)) {
|
if (workerId && !workerIds.includes(workerId)) {
|
||||||
if (workerMatchesFilter(workerId)) {
|
if (workerMatchesFilter(workerId)) {
|
||||||
workerIds.push(workerId);
|
workerIds.push(workerId);
|
||||||
workerIdToName[workerId] = workerName;
|
workerIdToName[workerId] = workerName;
|
||||||
|
workerIdToName[workerId + '_grade'] = workerGrade;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -897,7 +1427,8 @@ function renderShiftTimetable() {
|
|||||||
$("table#main-table td.worker").each(function () {
|
$("table#main-table td.worker").each(function () {
|
||||||
let workerId = $(this).attr("data-worker-id");
|
let workerId = $(this).attr("data-worker-id");
|
||||||
let workerName = $(this).find("span.name").text().trim();
|
let workerName = $(this).find("span.name").text().trim();
|
||||||
if (!cellMap[workerId]) cellMap[workerId] = { name: workerName, cells: {} };
|
let workerGrade = $(this).attr('data-grade') || '';
|
||||||
|
if (!cellMap[workerId]) cellMap[workerId] = { name: workerName, grade: workerGrade, cells: {} };
|
||||||
});
|
});
|
||||||
$("table#main-table td[data-week][data-day]").each(function () {
|
$("table#main-table td[data-week][data-day]").each(function () {
|
||||||
let td = $(this);
|
let td = $(this);
|
||||||
@@ -942,7 +1473,8 @@ function renderShiftTimetable() {
|
|||||||
color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`;
|
color = `linear-gradient(90deg, ${shiftColors[matchedShifts[0]]} 0 50%, ${shiftColors[matchedShifts[1]]} 50% 100%)`;
|
||||||
if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts.join(", ")})</span>`;
|
if (showShiftNames) shiftLabel = `<span style="font-size:0.9em; color:#333;"> (${matchedShifts.join(", ")})</span>`;
|
||||||
}
|
}
|
||||||
cellContent.push(`<div style="background:${color};margin:1px 0;padding:0 2px;border-radius:3px;display:inline-block;">${worker.name}${shiftLabel}</div>`);
|
let gradeText = showGrades ? ` <span style="font-size:0.85em;color:#444;">(${worker.grade||''})</span>` : '';
|
||||||
|
cellContent.push(`<div style="background:${color};margin:1px 0;padding:0 2px;border-radius:3px;display:inline-block;">${worker.name}${gradeText}${shiftLabel}</div>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row.append(`<td style="border:1px solid #888; min-width:40px; text-align:center;">${cellContent.join(" / ")}</td>`);
|
row.append(`<td style="border:1px solid #888; min-width:40px; text-align:center;">${cellContent.join(" / ")}</td>`);
|
||||||
@@ -952,6 +1484,127 @@ function renderShiftTimetable() {
|
|||||||
$("#shift-timetable-div").append(gridTable);
|
$("#shift-timetable-div").append(gridTable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateForExport(dateStr) {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
let d = new Date(dateStr);
|
||||||
|
if (Number.isNaN(d.getTime())) return dateStr;
|
||||||
|
let dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
let mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
let yyyy = d.getFullYear();
|
||||||
|
return `${dd}/${mm}/${yyyy}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShiftHeaderLabel(shift) {
|
||||||
|
let s = (shift || "").toLowerCase();
|
||||||
|
if (s.includes("twil")) return "TWIL";
|
||||||
|
if (s.includes("night") || s.includes("oncall")) return "NIGHT";
|
||||||
|
if (s.includes("weekend")) return "WEEKEND";
|
||||||
|
return (shift || "").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRotaExportTimetable() {
|
||||||
|
let weeks = [];
|
||||||
|
let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
let weekStartDates = {};
|
||||||
|
let showWorkers = $("#rota-export-show-workers").is(":checked");
|
||||||
|
|
||||||
|
// Build week index and week start dates from existing table data
|
||||||
|
$("table#main-table td[data-week][data-date]").each(function () {
|
||||||
|
let week = $(this).attr("data-week");
|
||||||
|
let date = $(this).attr("data-date");
|
||||||
|
if (!week || !date) return;
|
||||||
|
if (!weeks.includes(week)) weeks.push(week);
|
||||||
|
if (!weekStartDates[week] || new Date(date) < new Date(weekStartDates[week])) {
|
||||||
|
weekStartDates[week] = date;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
weeks = weeks.sort((a, b) => parseInt(a) - parseInt(b));
|
||||||
|
|
||||||
|
const orderedSelectedShifts = shiftDisplayOrder.filter(s => selectedShifts.has(s));
|
||||||
|
const dayShiftOrder = {};
|
||||||
|
daysOfWeek.forEach(day => {
|
||||||
|
dayShiftOrder[day] = orderedSelectedShifts.filter(shift => {
|
||||||
|
let found = false;
|
||||||
|
$(`table#main-table td[data-day='${day}']`).each(function () {
|
||||||
|
let shifts = ($(this).attr("data-shift") || "").split(",").map(x => x.trim()).filter(Boolean);
|
||||||
|
if (shifts.includes(shift)) {
|
||||||
|
found = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let table = $(`<table id="rota-export-table" class="shift-grid-table" style="margin-bottom:20px; border-collapse:collapse; width:auto; table-layout:fixed;"><caption>Rota Export (Shifts by Day)</caption></table>`);
|
||||||
|
let headerTop = $("<tr></tr>");
|
||||||
|
headerTop.append("<th style='border:1px solid #888; min-width:130px;'>Week Start</th>");
|
||||||
|
headerTop.append("<th style='border:1px solid #888; min-width:70px;'>Week</th>");
|
||||||
|
daysOfWeek.forEach(day => {
|
||||||
|
let span = Math.max(1, dayShiftOrder[day].length);
|
||||||
|
headerTop.append(`<th style='border:1px solid #888; min-width:120px;' colspan='${span}'>${day.toUpperCase()}</th>`);
|
||||||
|
});
|
||||||
|
table.append(headerTop);
|
||||||
|
|
||||||
|
let headerBottom = $("<tr></tr>");
|
||||||
|
headerBottom.append("<th style='border:1px solid #888; min-width:130px;'></th>");
|
||||||
|
headerBottom.append("<th style='border:1px solid #888; min-width:70px;'>No</th>");
|
||||||
|
daysOfWeek.forEach(day => {
|
||||||
|
if (dayShiftOrder[day].length === 0) {
|
||||||
|
headerBottom.append("<th style='border:1px solid #888; min-width:120px;'></th>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dayShiftOrder[day].forEach(shift => {
|
||||||
|
headerBottom.append(`<th style='border:1px solid #888; min-width:120px;'>${getShiftHeaderLabel(shift)}</th>`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
table.append(headerBottom);
|
||||||
|
|
||||||
|
weeks.forEach(week => {
|
||||||
|
let row = $("<tr></tr>");
|
||||||
|
let startDateFormatted = formatDateForExport(weekStartDates[week]);
|
||||||
|
row.append(`<td style='border:1px solid #888; font-weight:bold; vertical-align:top;'>${startDateFormatted}</td>`);
|
||||||
|
row.append(`<td style='border:1px solid #888; text-align:center; font-weight:bold; vertical-align:top;'>${week}</td>`);
|
||||||
|
|
||||||
|
daysOfWeek.forEach(day => {
|
||||||
|
const dayShifts = dayShiftOrder[day];
|
||||||
|
if (dayShifts.length === 0) {
|
||||||
|
row.append(`<td style='border:1px solid #888; color:#999; text-align:center;'>-</td>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dayShifts.forEach(shift => {
|
||||||
|
let workersForShift = new Set();
|
||||||
|
$(`table#main-table td[data-week='${week}'][data-day='${day}']`).each(function () {
|
||||||
|
let shifts = ($(this).attr("data-shift") || "").split(",").map(s => s.trim()).filter(Boolean);
|
||||||
|
if (!shifts.includes(shift)) return;
|
||||||
|
let workerName = $(this).closest("tr").find("td.worker span.name").text().trim();
|
||||||
|
if (workerName) workersForShift.add(workerName);
|
||||||
|
});
|
||||||
|
|
||||||
|
let workerList = Array.from(workersForShift).sort();
|
||||||
|
let text = "";
|
||||||
|
if (showWorkers) {
|
||||||
|
text = workerList.join("/");
|
||||||
|
} else {
|
||||||
|
text = workerList.length > 0 ? "X" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
row.append(`<td style='border:1px solid #888; vertical-align:top; text-align:center;'>${text}</td>`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
table.append(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (weeks.length === 0) {
|
||||||
|
table.append(`<tr><td colspan="16" style="border:1px solid #888; text-align:center; color:#666;">No rota data found.</td></tr>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#shift-timetable-div").append(table);
|
||||||
|
}
|
||||||
|
|
||||||
// Re-render worker checkboxes after timetable is updated (in case workers change)
|
// Re-render worker checkboxes after timetable is updated (in case workers change)
|
||||||
function rerenderWorkerCheckboxesIfNeeded() {
|
function rerenderWorkerCheckboxesIfNeeded() {
|
||||||
$("#shift-timetable-worker-filter").remove();
|
$("#shift-timetable-worker-filter").remove();
|
||||||
@@ -969,7 +1622,7 @@ rerenderWorkerCheckboxesIfNeeded();
|
|||||||
|
|
||||||
// Patch the timetable button logic to use renderShiftTimetable
|
// Patch the timetable button logic to use renderShiftTimetable
|
||||||
oshifts.forEach((shift) => {
|
oshifts.forEach((shift) => {
|
||||||
let button = $(`<button data-shift='${shift}'>${shift}</button>`);
|
let button = $(`<button data-shift='${shift}' draggable='true' title='Drag to reorder shifts'>${shift}</button>`);
|
||||||
button.css({
|
button.css({
|
||||||
background: "",
|
background: "",
|
||||||
border: "1px solid #888",
|
border: "1px solid #888",
|
||||||
@@ -994,6 +1647,42 @@ oshifts.forEach((shift) => {
|
|||||||
$("#shift-timetable-options").append(button);
|
$("#shift-timetable-options").append(button);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let draggedShiftButton = null;
|
||||||
|
|
||||||
|
$(document).on("dragstart", "#shift-timetable-options button[data-shift]", function (e) {
|
||||||
|
draggedShiftButton = this;
|
||||||
|
if (e.originalEvent && e.originalEvent.dataTransfer) {
|
||||||
|
e.originalEvent.dataTransfer.effectAllowed = "move";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("dragover", "#shift-timetable-options button[data-shift]", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("drop", "#shift-timetable-options button[data-shift]", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!draggedShiftButton || draggedShiftButton === this) return;
|
||||||
|
|
||||||
|
const $dragged = $(draggedShiftButton);
|
||||||
|
const $target = $(this);
|
||||||
|
const draggedIndex = $dragged.index();
|
||||||
|
const targetIndex = $target.index();
|
||||||
|
|
||||||
|
if (draggedIndex < targetIndex) {
|
||||||
|
$target.after($dragged);
|
||||||
|
} else {
|
||||||
|
$target.before($dragged);
|
||||||
|
}
|
||||||
|
|
||||||
|
shiftDisplayOrder = $("#shift-timetable-options button[data-shift]").map(function () {
|
||||||
|
return $(this).attr("data-shift");
|
||||||
|
}).get();
|
||||||
|
|
||||||
|
renderShiftTimetable();
|
||||||
|
draggedShiftButton = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Add help text to the shift timetable section
|
// Add help text to the shift timetable section
|
||||||
$("#shift-timetable-options").prepend(`
|
$("#shift-timetable-options").prepend(`
|
||||||
@@ -1094,9 +1783,19 @@ $("#rota-table tr td:first-child").each((n, td) => {
|
|||||||
|
|
||||||
function viewWorker(worker) {
|
function viewWorker(worker) {
|
||||||
ds = worker.dataset;
|
ds = worker.dataset;
|
||||||
|
let exactShiftsText = "(none)";
|
||||||
|
if (ds.exactShifts) {
|
||||||
|
try {
|
||||||
|
let parsed = JSON.parse(ds.exactShifts);
|
||||||
|
if (Object.keys(parsed).length > 0) {
|
||||||
|
exactShiftsText = Object.entries(parsed).map(([s, c]) => `${s}: ${c}`).join(", ");
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
dlg = $(`<div class='dialog' title='${ds.worker}'>
|
dlg = $(`<div class='dialog' title='${ds.worker}'>
|
||||||
Site: ${ds.site}</br>
|
Site: ${ds.site}</br>
|
||||||
FTE: ${ds.fte} (${ds.fte_adj})</br>
|
FTE: ${ds.fte} (${ds.fte_adj})</br>
|
||||||
|
Exact shifts: ${exactShiftsText}</br>
|
||||||
Start date: ${ds.start_date}</br>
|
Start date: ${ds.start_date}</br>
|
||||||
End date: ${ds.end_date}</br>
|
End date: ${ds.end_date}</br>
|
||||||
Non working days: ${ds.nwds}</br>
|
Non working days: ${ds.nwds}</br>
|
||||||
@@ -1530,6 +2229,16 @@ function syntaxHighlight(json) {
|
|||||||
const mainTable = document.getElementById("main-table");
|
const mainTable = document.getElementById("main-table");
|
||||||
if (!mainTable) return;
|
if (!mainTable) return;
|
||||||
|
|
||||||
|
// Ensure the header row is inside a proper <thead> element.
|
||||||
|
// Some exports use a bare <tr> as the first row; move it into a thead so CSS/sticky headers work.
|
||||||
|
if (!mainTable.tHead) {
|
||||||
|
const firstRow = mainTable.querySelector('tr');
|
||||||
|
if (firstRow) {
|
||||||
|
const thead = mainTable.createTHead();
|
||||||
|
thead.appendChild(firstRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to remove highlight from all headers
|
// Helper to remove highlight from all headers
|
||||||
function clearColHover() {
|
function clearColHover() {
|
||||||
const ths = mainTable.querySelectorAll("th.col-hover");
|
const ths = mainTable.querySelectorAll("th.col-hover");
|
||||||
@@ -1564,6 +2273,18 @@ $("#main-table").before(
|
|||||||
`<button id="show-display-settings" style="margin:8px 0 8px 0; float:right;">Display Settings</button>`
|
`<button id="show-display-settings" style="margin:8px 0 8px 0; float:right;">Display Settings</button>`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Ensure main table header is sticky and compact
|
||||||
|
if ($('#main-table-style').length === 0) {
|
||||||
|
$('head').append(`
|
||||||
|
<style id='main-table-style'>
|
||||||
|
/* Make main timetable header sticky */
|
||||||
|
#main-table thead th, #main-table thead td { position: sticky; top: 0; z-index: 60; background: #fff; font-size: 12px; }
|
||||||
|
/* Slight shadow to separate header from body */
|
||||||
|
#main-table thead th { box-shadow: 0 2px 6px rgba(0,0,0,0.06); }
|
||||||
|
</style>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Modal show/hide logic ---
|
// --- Modal show/hide logic ---
|
||||||
$("#show-display-settings").on("click", function() {
|
$("#show-display-settings").on("click", function() {
|
||||||
$("#display-settings-modal").show();
|
$("#display-settings-modal").show();
|
||||||
|
|||||||
+778
-205
File diff suppressed because it is too large
Load Diff
+244
-4
@@ -111,6 +111,100 @@ class OutOfProgramme(BaseModel):
|
|||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AvoidShiftOnDates(BaseModel):
|
||||||
|
"""Worker-level constraint: avoid listed shifts on given dates or date range."""
|
||||||
|
shifts: list[str]
|
||||||
|
start_date: Optional[datetime.date] = None
|
||||||
|
end_date: Optional[datetime.date] = None
|
||||||
|
dates: Optional[list[datetime.date]] = None
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("start_date", "end_date", mode="before")
|
||||||
|
def coerce_dates(cls, v):
|
||||||
|
# allow strings in common formats
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, datetime.date):
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
return datetime.datetime.strptime(v, fmt).date()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Cannot parse date: {v}")
|
||||||
|
|
||||||
|
|
||||||
|
class MaxDaysPerWeekBlockConstraint(BaseModel):
|
||||||
|
max_days: int = Field(
|
||||||
|
...,
|
||||||
|
ge=1,
|
||||||
|
description="Maximum number of worked days allowed inside each sliding week block.",
|
||||||
|
)
|
||||||
|
week_block: int = Field(
|
||||||
|
...,
|
||||||
|
ge=1,
|
||||||
|
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||||
|
)
|
||||||
|
ignore_dates: list[datetime.date] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of specific dates to exclude from this constraint.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MaxUniqueShiftsPerWeekBlockConstraint(BaseModel):
|
||||||
|
max_unique_shifts: int = Field(
|
||||||
|
...,
|
||||||
|
ge=1,
|
||||||
|
description="Maximum number of distinct shift names allowed inside each sliding week block.",
|
||||||
|
)
|
||||||
|
week_block: int = Field(
|
||||||
|
...,
|
||||||
|
ge=1,
|
||||||
|
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||||
|
)
|
||||||
|
ignore_dates: list[datetime.date] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of specific dates to exclude from this constraint.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftStartDate(BaseModel):
|
||||||
|
shift: str
|
||||||
|
start_date: datetime.date
|
||||||
|
|
||||||
|
@field_validator("start_date", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def coerce_date(cls, v):
|
||||||
|
if isinstance(v, datetime.date):
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
return datetime.datetime.strptime(v, fmt).date()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Cannot parse date: {v}")
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftEndDate(BaseModel):
|
||||||
|
shift: str
|
||||||
|
end_date: datetime.date
|
||||||
|
|
||||||
|
@field_validator("end_date", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def coerce_date(cls, v):
|
||||||
|
if isinstance(v, datetime.date):
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
return datetime.datetime.strptime(v, fmt).date()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Cannot parse date: {v}")
|
||||||
|
|
||||||
|
|
||||||
class Worker(BaseModel):
|
class Worker(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
site: str
|
site: str
|
||||||
@@ -147,15 +241,65 @@ class Worker(BaseModel):
|
|||||||
forced_assignments: List[tuple[int, str, str]] = [] # (week, day, shift_name)
|
forced_assignments: List[tuple[int, str, str]] = [] # (week, day, shift_name)
|
||||||
forced_assignments_by_date: List[tuple[datetime.date, str]] = [] # (date, shift_name)
|
forced_assignments_by_date: List[tuple[datetime.date, str]] = [] # (date, shift_name)
|
||||||
max_days_worked_per_week: int | None= None # Default: no limit, can be set to a specific number
|
max_days_worked_per_week: int | None= None # Default: no limit, can be set to a specific number
|
||||||
|
max_days_per_week_block: list[MaxDaysPerWeekBlockConstraint] = []
|
||||||
|
max_unique_shifts_per_week_block: list[MaxUniqueShiftsPerWeekBlockConstraint] = []
|
||||||
|
|
||||||
|
|
||||||
assign_as_block_preferences: dict[str, float] = {} # shift_name -> weight (positive = prefer block)
|
assign_as_block_preferences: dict[str, float] = {} # shift_name -> weight (positive = prefer block)
|
||||||
|
|
||||||
shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist
|
shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist
|
||||||
|
|
||||||
weekend_shift_target_number: int = 0
|
exact_shifts: dict[str, int] = {} # Map shift_name to exact number of shifts
|
||||||
|
groups: set[str] = set() # Groups that the worker belongs to
|
||||||
|
shift_start_dates: list[ShiftStartDate] = [] # List of ShiftStartDate models
|
||||||
|
shift_end_dates: list[ShiftEndDate] = [] # List of ShiftEndDate models
|
||||||
|
|
||||||
|
weekend_shift_target_number: float = 0.0
|
||||||
|
avoid_shifts_on_dates: list[AvoidShiftOnDates] = []
|
||||||
|
|
||||||
|
@field_validator("shift_start_dates", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def coerce_shift_start_dates(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return []
|
||||||
|
if isinstance(v, dict):
|
||||||
|
res = []
|
||||||
|
for shift, d in v.items():
|
||||||
|
res.append(ShiftStartDate(shift=shift, start_date=d))
|
||||||
|
return res
|
||||||
|
if isinstance(v, list):
|
||||||
|
res = []
|
||||||
|
for item in v:
|
||||||
|
if isinstance(item, ShiftStartDate):
|
||||||
|
res.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
res.append(ShiftStartDate(**item))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid ShiftStartDate item: {item}")
|
||||||
|
return res
|
||||||
|
raise ValueError("Must be a list or dictionary")
|
||||||
|
|
||||||
|
@field_validator("shift_end_dates", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def coerce_shift_end_dates(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return []
|
||||||
|
if isinstance(v, dict):
|
||||||
|
res = []
|
||||||
|
for shift, d in v.items():
|
||||||
|
res.append(ShiftEndDate(shift=shift, end_date=d))
|
||||||
|
return res
|
||||||
|
if isinstance(v, list):
|
||||||
|
res = []
|
||||||
|
for item in v:
|
||||||
|
if isinstance(item, ShiftEndDate):
|
||||||
|
res.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
res.append(ShiftEndDate(**item))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid ShiftEndDate item: {item}")
|
||||||
|
return res
|
||||||
|
raise ValueError("Must be a list or dictionary")
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
extra="allow",
|
extra="allow",
|
||||||
@@ -352,13 +496,88 @@ class Worker(BaseModel):
|
|||||||
self.proportion_rota_to_work = days_to_work / Rota.rota_days_length
|
self.proportion_rota_to_work = days_to_work / Rota.rota_days_length
|
||||||
self.days_to_work = days_to_work
|
self.days_to_work = days_to_work
|
||||||
|
|
||||||
|
# Shift-specific start/end dates and adjusted FTEs
|
||||||
|
self.calculated_shift_start_dates = {}
|
||||||
|
self.calculated_shift_end_dates = {}
|
||||||
|
self.proportion_rota_to_work_shifts = {}
|
||||||
|
|
||||||
|
# Convert list of ShiftStartDate/ShiftEndDate models/dicts to dictionary mapping for quick lookup
|
||||||
|
start_dates_dict = {item.shift: item.start_date for item in getattr(self, "shift_start_dates", []) if hasattr(item, "shift")}
|
||||||
|
end_dates_dict = {item.shift: item.end_date for item in getattr(self, "shift_end_dates", []) if hasattr(item, "shift")}
|
||||||
|
|
||||||
|
for shift in Rota.get_shifts():
|
||||||
|
s_date = start_dates_dict.get(shift.name, self.start_date)
|
||||||
|
if s_date is None:
|
||||||
|
calc_s = self.calculated_start_date
|
||||||
|
else:
|
||||||
|
if isinstance(s_date, str):
|
||||||
|
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
s_date = datetime.datetime.strptime(s_date, fmt).date()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
calc_s = s_date
|
||||||
|
if calc_s < Rota.start_date:
|
||||||
|
calc_s = Rota.start_date
|
||||||
|
elif calc_s > Rota.rota_end_date:
|
||||||
|
calc_s = Rota.rota_end_date
|
||||||
|
|
||||||
|
e_date = end_dates_dict.get(shift.name, self.end_date)
|
||||||
|
if e_date is None:
|
||||||
|
calc_e = self.calculated_end_date
|
||||||
|
else:
|
||||||
|
if isinstance(e_date, str):
|
||||||
|
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
e_date = datetime.datetime.strptime(e_date, fmt).date()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
calc_e = e_date
|
||||||
|
if calc_e > Rota.rota_end_date:
|
||||||
|
calc_e = Rota.rota_end_date
|
||||||
|
elif calc_e < Rota.start_date:
|
||||||
|
calc_e = Rota.start_date
|
||||||
|
|
||||||
|
if calc_s >= calc_e:
|
||||||
|
calc_s = Rota.rota_end_date
|
||||||
|
calc_e = Rota.rota_end_date
|
||||||
|
|
||||||
|
self.calculated_shift_start_dates[shift.name] = calc_s
|
||||||
|
self.calculated_shift_end_dates[shift.name] = calc_e
|
||||||
|
|
||||||
|
days_active = (calc_e - calc_s).days
|
||||||
|
|
||||||
|
# Subtract overlapping OOP days from the active period of this shift
|
||||||
|
for item in self.oop:
|
||||||
|
start_oop = item.start_date
|
||||||
|
end_oop = item.end_date
|
||||||
|
if isinstance(start_oop, datetime.date):
|
||||||
|
start_oop_date = start_oop
|
||||||
|
else:
|
||||||
|
start_oop_date = datetime.datetime.strptime(start_oop, "%d/%m/%y").date()
|
||||||
|
|
||||||
|
if isinstance(end_oop, datetime.date):
|
||||||
|
end_oop_date = end_oop
|
||||||
|
else:
|
||||||
|
end_oop_date = datetime.datetime.strptime(end_oop, "%d/%m/%y").date()
|
||||||
|
|
||||||
|
overlap_start = max(calc_s, start_oop_date)
|
||||||
|
overlap_end = min(calc_e, end_oop_date)
|
||||||
|
if overlap_start < overlap_end:
|
||||||
|
days_active -= (overlap_end - overlap_start).days
|
||||||
|
|
||||||
|
self.proportion_rota_to_work_shifts[shift.name] = max(0.0, days_active / Rota.rota_days_length)
|
||||||
|
|
||||||
# We have to adjust the full time equivalent for people who CCT / leave the rota early
|
# We have to adjust the full time equivalent for people who CCT / leave the rota early
|
||||||
self.fte_adj = self.fte * self.proportion_rota_to_work
|
self.fte_adj = self.fte * self.proportion_rota_to_work
|
||||||
|
|
||||||
self.fte_adj_shifts = {}
|
self.fte_adj_shifts = {}
|
||||||
if self.shift_fte_overrides:
|
for shift in Rota.get_shifts():
|
||||||
for shift, fte in self.shift_fte_overrides.items():
|
prop = self.proportion_rota_to_work_shifts.get(shift.name, self.proportion_rota_to_work)
|
||||||
self.fte_adj_shifts[shift] = fte * self.proportion_rota_to_work
|
base_fte = self.shift_fte_overrides.get(shift.name, self.fte)
|
||||||
|
self.fte_adj_shifts[shift.name] = base_fte * prop
|
||||||
|
|
||||||
|
|
||||||
if self.fte_adj > 100:
|
if self.fte_adj > 100:
|
||||||
@@ -395,6 +614,23 @@ class Worker(BaseModel):
|
|||||||
style="red",
|
style="red",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Register any per-worker avoid-shift-on-dates rules with the rota builder
|
||||||
|
try:
|
||||||
|
for av in getattr(self, "avoid_shifts_on_dates", []):
|
||||||
|
entry = {
|
||||||
|
"names": [self.name],
|
||||||
|
"shifts": av.shifts,
|
||||||
|
"start_date": av.start_date,
|
||||||
|
"end_date": av.end_date,
|
||||||
|
"dates": av.dates,
|
||||||
|
"reason": getattr(av, "reason", None),
|
||||||
|
}
|
||||||
|
# append to rota-level list; the model builder will enforce these
|
||||||
|
if hasattr(Rota.constraint_options_model, "avoid_shifts_by_worker_dates"):
|
||||||
|
Rota.constraint_options_model.avoid_shifts_by_worker_dates.append(entry)
|
||||||
|
except Exception:
|
||||||
|
Rota.add_warning("Worker/avoid_shifts_on_dates", f"Failed to register avoid_shifts_on_dates for {self.name}")
|
||||||
|
|
||||||
def __lt__(self, other) -> bool:
|
def __lt__(self, other) -> bool:
|
||||||
return (self.site, self.grade, self.fte_adj, self.name) < (
|
return (self.site, self.grade, self.fte_adj, self.name) < (
|
||||||
other.site,
|
other.site,
|
||||||
@@ -474,3 +710,7 @@ class Worker(BaseModel):
|
|||||||
def force_assign_shift_by_date(self, date: datetime.date, shift_name: str):
|
def force_assign_shift_by_date(self, date: datetime.date, shift_name: str):
|
||||||
"""Force this worker to be assigned to a shift on a specific date."""
|
"""Force this worker to be assigned to a shift on a specific date."""
|
||||||
self.forced_assignments_by_date.append((date, shift_name))
|
self.forced_assignments_by_date.append((date, shift_name))
|
||||||
|
|
||||||
|
def add_max_days_per_week_block_constraint(self, max_days: int, week_block: int, ignore_dates: Optional[List[datetime.date]] = None):
|
||||||
|
"""Add a constraint to limit the number of days worked in any sliding block of weeks."""
|
||||||
|
self.max_days_per_week_block.append(MaxDaysPerWeekBlockConstraint(max_days=max_days, week_block=week_block, ignore_dates=ignore_dates or []))
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import datetime
|
||||||
|
from rota_generator.shifts import RotaBuilder, SingleShift, days
|
||||||
|
from rota_generator.workers import Worker, AvoidShiftOnDates
|
||||||
|
|
||||||
|
|
||||||
|
def build_simple_rota():
|
||||||
|
start = datetime.date(2026, 4, 6) # Monday
|
||||||
|
Rota = RotaBuilder(start, weeks_to_rota=4, name="test_rota")
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(sites=("exeter",), name="night_weekday", length=12.25, days=days[:4], workers_required=1),
|
||||||
|
SingleShift(sites=("exeter",), name="plymouth_twilight", length=8, days=days[5:], workers_required=1),
|
||||||
|
)
|
||||||
|
Rota.build_shifts()
|
||||||
|
return Rota
|
||||||
|
|
||||||
|
|
||||||
|
def test_avoid_single_date_for_worker():
|
||||||
|
Rota = build_simple_rota()
|
||||||
|
# create a worker who should avoid night_weekday on 2026-04-07
|
||||||
|
avoid = AvoidShiftOnDates(shifts=["night_weekday"], dates=[datetime.date(2026, 4, 7)])
|
||||||
|
w = Worker(name="Test Worker", site="exeter", grade=3, fte=100, avoid_shifts_on_dates=[avoid])
|
||||||
|
Rota.add_worker(w)
|
||||||
|
Rota.build_workers()
|
||||||
|
|
||||||
|
# find week/day corresponding to 2026-04-07
|
||||||
|
weeks_days = [(wk, d) for wk, d in Rota.weeks_days_product if Rota.week_day_date_map[(wk, d)] == datetime.date(2026, 4, 7)]
|
||||||
|
assert len(weeks_days) == 1
|
||||||
|
wk, day = weeks_days[0]
|
||||||
|
|
||||||
|
# The model should mark works[worker.id, wk, day, 'night_weekday'] impossible -> check unavailable_to_work
|
||||||
|
assert (w.id, wk, day) not in Rota.unavailable_to_work
|
||||||
|
|
||||||
|
# Build and solve minimal model (but we can inspect constraints). Instead, check that the avoid rule was registered
|
||||||
|
found = False
|
||||||
|
for c in Rota.constraint_options_model.avoid_shifts_by_worker_dates:
|
||||||
|
if "names" in c and w.name in c["names"]:
|
||||||
|
found = True
|
||||||
|
# ensure the date is represented
|
||||||
|
assert c.get("dates") is not None and datetime.date(2026,4,7) in c.get("dates")
|
||||||
|
assert found
|
||||||
|
|
||||||
|
|
||||||
|
def test_avoid_date_range_for_worker_applies_to_multiple_days():
|
||||||
|
Rota = build_simple_rota()
|
||||||
|
# Avoid night_weekday for 2026-04-06 through 2026-04-09
|
||||||
|
avoid = AvoidShiftOnDates(shifts=["night_weekday"], start_date="06/04/2026", end_date="09/04/2026")
|
||||||
|
w = Worker(name="Range Worker", site="exeter", grade=3, fte=100, avoid_shifts_on_dates=[avoid])
|
||||||
|
Rota.add_worker(w)
|
||||||
|
Rota.build_workers()
|
||||||
|
|
||||||
|
# Collect dates between start and end
|
||||||
|
sd = datetime.date(2026,4,6)
|
||||||
|
ed = datetime.date(2026,4,9)
|
||||||
|
dates_in_range = [d for d in Rota.get_date_range(sd, ed)]
|
||||||
|
assert len(dates_in_range) == 4
|
||||||
|
|
||||||
|
# Ensure the rota-level constraint was added
|
||||||
|
found = False
|
||||||
|
for c in Rota.constraint_options_model.avoid_shifts_by_worker_dates:
|
||||||
|
if w.name in c.get("names", []):
|
||||||
|
found = True
|
||||||
|
assert c.get("start_date") == sd
|
||||||
|
assert c.get("end_date") == ed
|
||||||
|
assert found
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_rota_for_visual_inspection():
|
||||||
|
Rota = build_simple_rota()
|
||||||
|
# add two workers
|
||||||
|
avoid = AvoidShiftOnDates(shifts=["night_weekday"], start_date="06/04/2026", end_date="26/04/2026")
|
||||||
|
w1 = Worker(name="Export A", site="exeter", grade=3, fte=100, avoid_shifts_on_dates=[avoid])
|
||||||
|
w2 = Worker(name="Export B", site="exeter", grade=3, fte=100)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Rota.add_workers((w1, w2))
|
||||||
|
|
||||||
|
# Build model but do not solve, then export
|
||||||
|
Rota.name = "export_test_rota"
|
||||||
|
Rota.build_and_solve(solve=True)
|
||||||
|
|
||||||
|
assert Rota.results.solver.status == "ok", "Rota should be feasible"
|
||||||
|
|
||||||
|
# Next we check that worker w1 has no night_weekday shifts assigned between 06/04/2026 and 26/04/2026
|
||||||
|
sd = datetime.date(2026,4,6)
|
||||||
|
ed = datetime.date(2026,4,26)
|
||||||
|
for single_date in Rota.get_date_range(sd, ed):
|
||||||
|
wk_day = Rota.get_week_day_by_date(single_date)
|
||||||
|
assigned_shifts = Rota.get_assigned_shifts_for_worker_on_day(w1.id, wk_day[0], wk_day[1])
|
||||||
|
for sh in assigned_shifts:
|
||||||
|
assert sh.name != "night_weekday", f"Worker {w1.name} was assigned forbidden shift on {single_date}"
|
||||||
|
|
||||||
|
# We also check that w1 has 4 night shifts in total (the max possible in 4 weeks)
|
||||||
|
night_shift_count = 0
|
||||||
|
for wk, day in Rota.weeks_days_product:
|
||||||
|
assigned_shifts = Rota.get_assigned_shifts_for_worker_on_day(w1.id, wk, day)
|
||||||
|
for sh in assigned_shifts:
|
||||||
|
if sh.name == "night_weekday":
|
||||||
|
night_shift_count += 1
|
||||||
|
assert night_shift_count == 4, f"Worker {w1.name} should have 4 night shifts, has {night_shift_count}"
|
||||||
|
|
||||||
|
# And that w2 has 12 night shifts (no restrictions)
|
||||||
|
night_shift_count_w2 = 0
|
||||||
|
for wk, day in Rota.weeks_days_product:
|
||||||
|
assigned_shifts = Rota.get_assigned_shifts_for_worker_on_day(w2.id, wk, day)
|
||||||
|
for sh in assigned_shifts:
|
||||||
|
if sh.name == "night_weekday":
|
||||||
|
night_shift_count_w2 += 1
|
||||||
|
assert night_shift_count_w2 == 12, f"Worker {w2.name} should have 12 night shifts, has {night_shift_count_w2}"
|
||||||
|
|
||||||
|
# Next check that adding a short avoid to w2 results in an infeasible model
|
||||||
|
avoid2 = AvoidShiftOnDates(shifts=["night_weekday"], start_date="06/04/2026", end_date="07/04/2026")
|
||||||
|
w2.avoid_shifts_on_dates.append(avoid2)
|
||||||
|
Rota.build_and_solve(solve=True)
|
||||||
|
|
||||||
|
assert Rota.results.solver.termination_condition == "infeasible", "Rota should be infeasible due to avoid shifts on dates constraints"
|
||||||
|
|
||||||
|
|
||||||
|
Rota.export_rota_to_html("avoid_shifts_on_dates_test.html", folder="tests")
|
||||||
|
|
||||||
+59
-2
@@ -163,7 +163,7 @@ def test_nwd_force_as_block_force_split():
|
|||||||
for worker in Rota.workers:
|
for worker in Rota.workers:
|
||||||
shifts = Rota.get_worker_shift_list(worker)
|
shifts = Rota.get_worker_shift_list(worker)
|
||||||
shifts_string = "".join([i if i != "" else "-" for i in shifts])
|
shifts_string = "".join([i if i != "" else "-" for i in shifts])
|
||||||
for week in weeks_from_list(shifts_string[7 * 5 :]):
|
for week in weeks_from_list(shifts_string[7 * 6 :]):
|
||||||
assert week in ("-----ww", "dddddww")
|
assert week in ("-----ww", "dddddww")
|
||||||
if worker.name == "worker1":
|
if worker.name == "worker1":
|
||||||
assert shifts_string[: 7 * 5].count("ww-") == 4
|
assert shifts_string[: 7 * 5].count("ww-") == 4
|
||||||
@@ -213,4 +213,61 @@ def test_nwd_testing():
|
|||||||
if worker.name == "worker1":
|
if worker.name == "worker1":
|
||||||
assert week == "dddddww"
|
assert week == "dddddww"
|
||||||
else:
|
else:
|
||||||
assert week == "-----ww"
|
assert week == "-----ww"
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_as_block_unless_nwd_with_subblocks():
|
||||||
|
# Test that a Fri/Sat/Sun shift can be split into Fri and Sat/Sun blocks
|
||||||
|
# if a worker does not work on Fridays.
|
||||||
|
Rota = setup_rota(weeks_to_rota=2)
|
||||||
|
start_date = Rota.start_date
|
||||||
|
|
||||||
|
# worker1 does not work on Fridays (Fri is NWD)
|
||||||
|
worker1 = Worker(
|
||||||
|
name="worker1", site="group1", grade=1,
|
||||||
|
nwds=[{"day": "Fri", "start_date": start_date, "end_date": start_date + datetime.timedelta(weeks=2)}],
|
||||||
|
)
|
||||||
|
# worker2 works normally
|
||||||
|
worker2 = Worker(name="worker2", site="group1", grade=1)
|
||||||
|
|
||||||
|
Rota.add_workers((worker1, worker2))
|
||||||
|
|
||||||
|
# Fri/Sat/Sun shift, requiring 1 worker, forced as block unless NWD
|
||||||
|
# We specify sub-blocks: [["Fri"], ["Sat", "Sun"]]
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",), name="weekend", length=12.5,
|
||||||
|
days=["Fri", "Sat", "Sun"],
|
||||||
|
workers_required=1,
|
||||||
|
force_as_block_unless_nwd=[["Fri"], ["Sat", "Sun"]],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# We remove no valid shifts warning
|
||||||
|
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||||
|
|
||||||
|
Rota.build_and_solve()
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
# Check assignments:
|
||||||
|
# Since worker1 has NWD on Friday:
|
||||||
|
# - worker1 cannot work Friday.
|
||||||
|
# - But worker1 can work Sat and Sun as a block!
|
||||||
|
# So on Saturday and Sunday, worker1 should be assigned.
|
||||||
|
# On Friday, worker2 should be assigned.
|
||||||
|
for week in Rota.weeks:
|
||||||
|
w1_fri = Rota.model.works[worker1.id, week, "Fri", "weekend"].value
|
||||||
|
w1_sat = Rota.model.works[worker1.id, week, "Sat", "weekend"].value
|
||||||
|
w1_sun = Rota.model.works[worker1.id, week, "Sun", "weekend"].value
|
||||||
|
|
||||||
|
w2_fri = Rota.model.works[worker2.id, week, "Fri", "weekend"].value
|
||||||
|
w2_sat = Rota.model.works[worker2.id, week, "Sat", "weekend"].value
|
||||||
|
w2_sun = Rota.model.works[worker2.id, week, "Sun", "weekend"].value
|
||||||
|
|
||||||
|
assert w1_fri == 0
|
||||||
|
|
||||||
|
# Either worker1 works Sat/Sun and worker2 works Fri (split block)
|
||||||
|
# OR worker2 works Fri/Sat/Sun (full block) and worker1 works nothing
|
||||||
|
is_split = (w1_sat == 1 and w1_sun == 1 and w2_fri == 1 and w2_sat == 0 and w2_sun == 0)
|
||||||
|
is_full_w2 = (w1_sat == 0 and w1_sun == 0 and w2_fri == 1 and w2_sat == 1 and w2_sun == 1)
|
||||||
|
assert is_split or is_full_w2
|
||||||
+168
-1
@@ -1,11 +1,19 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
from rota_generator.shifts import (
|
from rota_generator.shifts import (
|
||||||
InvalidShift, MaxShiftsPerWeekBlockConstraint, MaxShiftsPerWeekConstraint,
|
InvalidShift, MaxShiftsPerWeekBlockConstraint, MaxShiftsPerWeekConstraint,
|
||||||
NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift,
|
NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift,
|
||||||
WarningTermination, days, WorkerRequirement
|
WarningTermination, days, WorkerRequirement
|
||||||
)
|
)
|
||||||
from rota_generator.workers import NotAvailableToWork, WorkRequests, Worker, generate_not_available_to_works
|
from rota_generator.workers import (
|
||||||
|
MaxDaysPerWeekBlockConstraint,
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint,
|
||||||
|
NotAvailableToWork,
|
||||||
|
WorkRequests,
|
||||||
|
Worker,
|
||||||
|
generate_not_available_to_works,
|
||||||
|
)
|
||||||
|
|
||||||
def generate_basic_rota(weeks_to_rota=10, workers=2, start_date=datetime.date(2022, 3, 7)):
|
def generate_basic_rota(weeks_to_rota=10, workers=2, start_date=datetime.date(2022, 3, 7)):
|
||||||
Rota = RotaBuilder(
|
Rota = RotaBuilder(
|
||||||
@@ -89,6 +97,165 @@ def test_max_shifts_per_week_block_shift_number_fail():
|
|||||||
Rota.export_rota_to_html("max_shifts_per_week_block")
|
Rota.export_rota_to_html("max_shifts_per_week_block")
|
||||||
assert Rota.results.solver.status in ("warning", "error")
|
assert Rota.results.solver.status in ("warning", "error")
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_days_per_week_block_fail():
|
||||||
|
Rota = RotaBuilder(datetime.date(2022, 3, 7), weeks_to_rota=2)
|
||||||
|
Rota.add_worker(
|
||||||
|
Worker(
|
||||||
|
name="worker1",
|
||||||
|
site="group1",
|
||||||
|
grade=1,
|
||||||
|
max_days_per_week_block=[
|
||||||
|
MaxDaysPerWeekBlockConstraint(max_days=1, week_block=1)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days[:3],
|
||||||
|
workers_required=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.termination_condition == "infeasible"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_unique_shifts_per_week_block_fail():
|
||||||
|
Rota = RotaBuilder(datetime.date(2022, 3, 7), weeks_to_rota=2)
|
||||||
|
Rota.add_worker(
|
||||||
|
Worker(
|
||||||
|
name="worker1",
|
||||||
|
site="group1",
|
||||||
|
grade=1,
|
||||||
|
max_unique_shifts_per_week_block=[
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=1, week_block=1)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=["Mon"],
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="b",
|
||||||
|
length=12.5,
|
||||||
|
days=["Tue"],
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.termination_condition == "infeasible"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_block_constraint_validation():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
MaxDaysPerWeekBlockConstraint(max_days=0, week_block=1)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=0, week_block=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_days_per_week_block_pass():
|
||||||
|
"""Positive test: constraint allowing reasonable days."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
# Add constraint allowing up to 4 days per week
|
||||||
|
Rota.workers[0].max_days_per_week_block = [
|
||||||
|
MaxDaysPerWeekBlockConstraint(max_days=4, week_block=1)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
assert Rota.results.solver.termination_condition == "optimal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_unique_shifts_per_week_block_pass():
|
||||||
|
"""Positive test: allowing multiple shifts within week block."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
# Add constraint allowing up to 2 unique shifts per week
|
||||||
|
Rota.workers[0].max_unique_shifts_per_week_block = [
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=2, week_block=1)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
assert Rota.results.solver.termination_condition == "optimal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_days_per_week_block_with_ignore_dates():
|
||||||
|
"""Test max_days_per_week_block with ignored dates works without error."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
rota_start = Rota.start_date
|
||||||
|
ignored_date = rota_start # Ignore first Monday
|
||||||
|
# Add constraint with ignore_dates
|
||||||
|
Rota.workers[0].max_days_per_week_block = [
|
||||||
|
MaxDaysPerWeekBlockConstraint(
|
||||||
|
max_days=3,
|
||||||
|
week_block=1,
|
||||||
|
ignore_dates=[ignored_date],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_max_unique_shifts_per_week_block_with_ignore_dates():
|
||||||
|
"""Test max_unique_shifts_per_week_block with ignored dates works without error."""
|
||||||
|
Rota = generate_basic_rota()
|
||||||
|
rota_start = Rota.start_date
|
||||||
|
ignored_date = rota_start + datetime.timedelta(days=1) # Ignore Tuesday
|
||||||
|
# Add constraint with ignore_dates
|
||||||
|
Rota.workers[0].max_unique_shifts_per_week_block = [
|
||||||
|
MaxUniqueShiftsPerWeekBlockConstraint(
|
||||||
|
max_unique_shifts=1,
|
||||||
|
week_block=1,
|
||||||
|
ignore_dates=[ignored_date],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# Add shifts to the rota
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1", "group2"),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.000})
|
||||||
|
# This should still be feasible with only one shift type in main rota
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
def test_pre_shift_constraint():
|
def test_pre_shift_constraint():
|
||||||
Rota = generate_basic_rota()
|
Rota = generate_basic_rota()
|
||||||
for i, d in enumerate(days[:6]):
|
for i, d in enumerate(days[:6]):
|
||||||
|
|||||||
+508
-2
@@ -1,8 +1,9 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from rota_generator.shifts import InvalidShift, MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, RotaBuilder, SingleShift, WarningTermination, WorkerRequirement, days
|
from rota_generator.shifts import InvalidShift, MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, RotaBuilder, SingleShift, WarningTermination, WorkerRequirement, days
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from rota_generator.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works
|
from rota_generator.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works, ShiftStartDate, ShiftEndDate
|
||||||
from rota_generator.workers import WorkRequests
|
from rota_generator.workers import WorkRequests
|
||||||
from rota_generator.shifts import PreShiftConstraint
|
from rota_generator.shifts import PreShiftConstraint
|
||||||
|
|
||||||
@@ -1624,4 +1625,509 @@ def test_worker_hard_day_exclusion2():
|
|||||||
for week in range(16):
|
for week in range(16):
|
||||||
sat_idx = week * 7 + days.index("Sat")
|
sat_idx = week * 7 + days.index("Sat")
|
||||||
sun_idx = week * 7 + days.index("Sun")
|
sun_idx = week * 7 + days.index("Sun")
|
||||||
assert not (shift_list[sat_idx] == "a" and shift_list[sun_idx] == "a"), f"Worker {worker.name} has 'a' on both Mon and Tue in week {week}, which should not happen due to hard day exclusion"
|
assert not (shift_list[sat_idx] == "a" and shift_list[sun_idx] == "a"), f"Worker {worker.name} has 'a' on both Mon and Tue in week {week}, which should not happen due to hard day exclusion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_shifts_distribution():
|
||||||
|
weeks_to_rota = 10
|
||||||
|
start_date = datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
Rota = RotaBuilder(
|
||||||
|
start_date,
|
||||||
|
weeks_to_rota=weeks_to_rota,
|
||||||
|
)
|
||||||
|
|
||||||
|
workers = [
|
||||||
|
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
|
||||||
|
Worker(name="worker2", site="group1", grade=1, fte=100),
|
||||||
|
Worker(name="worker3", site="group1", grade=1, fte=50),
|
||||||
|
]
|
||||||
|
Rota.add_workers(workers)
|
||||||
|
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
hard_constrain_shift=True,
|
||||||
|
balance_offset=0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Rota.build_and_solve()
|
||||||
|
|
||||||
|
assert Rota.workers_name_map["worker1"].shift_target_number["a"] == 4
|
||||||
|
assert Rota.workers_name_map["worker2"].shift_target_number["a"] == 4
|
||||||
|
assert Rota.workers_name_map["worker3"].shift_target_number["a"] == 2
|
||||||
|
|
||||||
|
# Verify actual assigned counts are exactly correct
|
||||||
|
worker_shift_counts = {w.name: 0 for w in Rota.get_workers()}
|
||||||
|
for week, day, shiftname in Rota.get_all_shiftname_combinations():
|
||||||
|
for worker in Rota.get_workers():
|
||||||
|
val = Rota.model.works[worker.id, week, day, shiftname].value
|
||||||
|
if val is not None and val > 0.5:
|
||||||
|
if shiftname == "a":
|
||||||
|
worker_shift_counts[worker.name] += 1
|
||||||
|
|
||||||
|
assert worker_shift_counts["worker1"] == 4
|
||||||
|
assert worker_shift_counts["worker2"] == 4
|
||||||
|
assert worker_shift_counts["worker3"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_shifts_distribution_unbalanced():
|
||||||
|
weeks_to_rota = 16
|
||||||
|
start_date = datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
Rota = RotaBuilder(
|
||||||
|
start_date,
|
||||||
|
weeks_to_rota=weeks_to_rota,
|
||||||
|
)
|
||||||
|
|
||||||
|
workers = [
|
||||||
|
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
|
||||||
|
Worker(name="worker2", site="group1", grade=1, fte=100),
|
||||||
|
Worker(name="worker3", site="group1", grade=1, fte=50),
|
||||||
|
]
|
||||||
|
Rota.add_workers(workers)
|
||||||
|
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
hard_constrain_shift=True,
|
||||||
|
balance_offset=0,
|
||||||
|
),
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="b",
|
||||||
|
length=12.5,
|
||||||
|
days=("Tue",),
|
||||||
|
workers_required=1,
|
||||||
|
hard_constrain_shift=True,
|
||||||
|
balance_offset=0.8,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Rota.build_and_solve()
|
||||||
|
Rota.export_rota_to_html("test_exact_shifts_distribution_unbalanced", folder="tests")
|
||||||
|
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
assert Rota.workers_name_map["worker1"].shift_target_number["a"] == 4
|
||||||
|
assert Rota.workers_name_map["worker2"].shift_target_number["a"] == 8
|
||||||
|
assert Rota.workers_name_map["worker3"].shift_target_number["a"] == 4
|
||||||
|
|
||||||
|
assert Rota.workers_name_map["worker1"].shift_target_number["b"] == 6.4
|
||||||
|
assert Rota.workers_name_map["worker2"].shift_target_number["b"] == 6.4
|
||||||
|
assert Rota.workers_name_map["worker3"].shift_target_number["b"] == 3.2
|
||||||
|
|
||||||
|
# Verify actual assigned counts are exactly correct
|
||||||
|
worker_shift_counts_a = {w.name: 0 for w in Rota.get_workers()}
|
||||||
|
worker_shift_counts_b = {w.name: 0 for w in Rota.get_workers()}
|
||||||
|
for week, day, shiftname in Rota.get_all_shiftname_combinations():
|
||||||
|
for worker in Rota.get_workers():
|
||||||
|
val = Rota.model.works[worker.id, week, day, shiftname].value
|
||||||
|
if val is not None and val > 0.5:
|
||||||
|
if shiftname == "a":
|
||||||
|
worker_shift_counts_a[worker.name] += 1
|
||||||
|
elif shiftname == "b":
|
||||||
|
worker_shift_counts_b[worker.name] += 1
|
||||||
|
|
||||||
|
assert worker_shift_counts_a["worker1"] == 4
|
||||||
|
assert worker_shift_counts_a["worker2"] == 8
|
||||||
|
assert worker_shift_counts_a["worker3"] == 4
|
||||||
|
|
||||||
|
assert worker_shift_counts_b["worker1"] in (6, 7) # Allowing for rounding differences
|
||||||
|
assert worker_shift_counts_b["worker2"] in (6, 7) # Allowing for rounding differences
|
||||||
|
assert worker_shift_counts_b["worker3"] in (3, 4) # Allowing for rounding differences
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_shifts_invalid_site_warning():
|
||||||
|
weeks_to_rota = 2
|
||||||
|
start_date = datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
Rota = RotaBuilder(
|
||||||
|
start_date,
|
||||||
|
weeks_to_rota=weeks_to_rota,
|
||||||
|
)
|
||||||
|
Rota.add_workers([
|
||||||
|
Worker(name="worker1", site="group2", grade=1, fte=100, exact_shifts={"a": 4}),
|
||||||
|
])
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
SingleShift(
|
||||||
|
sites=("group2",),
|
||||||
|
name="b",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_workers()
|
||||||
|
|
||||||
|
warnings = Rota.get_warnings("Invalid exact shift site")
|
||||||
|
assert len(warnings) > 0
|
||||||
|
assert "group2 is not in the shift sites" in warnings[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_shifts_invalid_shift_warning():
|
||||||
|
weeks_to_rota = 2
|
||||||
|
start_date = datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
Rota = RotaBuilder(
|
||||||
|
start_date,
|
||||||
|
weeks_to_rota=weeks_to_rota,
|
||||||
|
)
|
||||||
|
Rota.add_workers([
|
||||||
|
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"nonexistent": 4}),
|
||||||
|
])
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_workers()
|
||||||
|
|
||||||
|
warnings = Rota.get_warnings("Invalid exact shift")
|
||||||
|
assert len(warnings) > 0
|
||||||
|
assert "non-existent shift nonexistent" in warnings[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_shifts_exceed_total_warning():
|
||||||
|
weeks_to_rota = 2
|
||||||
|
start_date = datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
Rota = RotaBuilder(
|
||||||
|
start_date,
|
||||||
|
weeks_to_rota=weeks_to_rota,
|
||||||
|
)
|
||||||
|
Rota.add_workers([
|
||||||
|
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
|
||||||
|
])
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_workers()
|
||||||
|
Rota.build_model()
|
||||||
|
|
||||||
|
warnings = Rota.get_warnings("Exact shifts exceed total shifts")
|
||||||
|
assert len(warnings) > 0
|
||||||
|
assert "total exact shifts (4) exceeds total required shifts" in warnings[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_export_worker_details_and_unavailable_reason():
|
||||||
|
weeks_to_rota = 2
|
||||||
|
start_date = datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
Rota = RotaBuilder(
|
||||||
|
start_date,
|
||||||
|
weeks_to_rota=weeks_to_rota,
|
||||||
|
)
|
||||||
|
Rota.add_workers([
|
||||||
|
Worker(
|
||||||
|
name="worker1",
|
||||||
|
site="group1",
|
||||||
|
grade=1,
|
||||||
|
fte=100,
|
||||||
|
exact_shifts={"a": 1},
|
||||||
|
start_date=datetime.date(2022, 3, 14)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_workers()
|
||||||
|
Rota.build_model()
|
||||||
|
|
||||||
|
html = Rota.get_worker_timetable_html()
|
||||||
|
|
||||||
|
assert 'data-exact-shifts=\'{"a": 1}\'' in html
|
||||||
|
assert 'title=\' (2022-03-07) / START DATE: 2022-03-14\'' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_groups_selector():
|
||||||
|
weeks_to_rota = 2
|
||||||
|
start_date = datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
Rota = RotaBuilder(
|
||||||
|
start_date,
|
||||||
|
weeks_to_rota=weeks_to_rota,
|
||||||
|
)
|
||||||
|
Rota.add_workers([
|
||||||
|
Worker(name="worker1", site="group1", grade=1, fte=100, groups={"groupA", "groupB"}),
|
||||||
|
Worker(name="worker2", site="group1", grade=1, fte=100, groups={"groupB", "groupC"}),
|
||||||
|
])
|
||||||
|
|
||||||
|
workers_A = Rota.get_workers_in_group("groupA")
|
||||||
|
workers_B = Rota.get_workers_in_group("groupB")
|
||||||
|
workers_C = Rota.get_workers_in_group("groupC")
|
||||||
|
workers_D = Rota.get_workers_in_group("groupD")
|
||||||
|
|
||||||
|
assert [w.name for w in workers_A] == ["worker1"]
|
||||||
|
assert set(w.name for w in workers_B) == {"worker1", "worker2"}
|
||||||
|
assert [w.name for w in workers_C] == ["worker2"]
|
||||||
|
assert workers_D == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_groups_overlapping_validation():
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
include_groups={"pool1", "pool2"},
|
||||||
|
exclude_groups={"pool2", "pool3"},
|
||||||
|
)
|
||||||
|
assert "include_groups and exclude_groups cannot overlap" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_group_availability_inclusion():
|
||||||
|
weeks_to_rota = 2
|
||||||
|
start_date = datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
Rota = RotaBuilder(
|
||||||
|
start_date,
|
||||||
|
weeks_to_rota=weeks_to_rota,
|
||||||
|
)
|
||||||
|
Rota.add_workers([
|
||||||
|
Worker(name="worker1", site="group1", grade=1, fte=100),
|
||||||
|
Worker(name="worker2", site="group2", grade=1, fte=100, groups={"senior"}),
|
||||||
|
Worker(name="worker3", site="group2", grade=1, fte=100, groups={"junior"}),
|
||||||
|
])
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
include_groups={"senior"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||||
|
Rota.build_and_solve()
|
||||||
|
Rota.export_rota_to_html("test_shift_group_availability_inclusion", folder="tests")
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
worker_shift_counts = {w.name: 0 for w in Rota.get_workers()}
|
||||||
|
for week, day, shiftname in Rota.get_all_shiftname_combinations():
|
||||||
|
for worker in Rota.get_workers():
|
||||||
|
val = Rota.model.works[worker.id, week, day, shiftname].value
|
||||||
|
if val is not None and val > 0.5:
|
||||||
|
worker_shift_counts[worker.name] += 1
|
||||||
|
|
||||||
|
assert worker_shift_counts["worker1"] in (1, 2)
|
||||||
|
assert worker_shift_counts["worker2"] in (1, 2)
|
||||||
|
assert worker_shift_counts["worker3"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_group_availability_exclusion():
|
||||||
|
weeks_to_rota = 2
|
||||||
|
start_date = datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
Rota = RotaBuilder(
|
||||||
|
start_date,
|
||||||
|
weeks_to_rota=weeks_to_rota,
|
||||||
|
)
|
||||||
|
Rota.add_workers([
|
||||||
|
Worker(name="worker1", site="group1", grade=1, fte=100, groups={"senior"}),
|
||||||
|
Worker(name="worker2", site="group1", grade=1, fte=100, groups={"junior"}),
|
||||||
|
])
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
exclude_groups={"junior"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||||
|
Rota.build_and_solve()
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
worker_shift_counts = {w.name: 0 for w in Rota.get_workers()}
|
||||||
|
for week, day, shiftname in Rota.get_all_shiftname_combinations():
|
||||||
|
for worker in Rota.get_workers():
|
||||||
|
val = Rota.model.works[worker.id, week, day, shiftname].value
|
||||||
|
if val is not None and val > 0.5:
|
||||||
|
worker_shift_counts[worker.name] += 1
|
||||||
|
|
||||||
|
assert worker_shift_counts["worker1"] == 2
|
||||||
|
assert worker_shift_counts["worker2"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_dependent_active_dates_constraints():
|
||||||
|
weeks_to_rota = 10
|
||||||
|
start_date = datetime.date(2022, 3, 7) # Mon
|
||||||
|
|
||||||
|
Rota = RotaBuilder(
|
||||||
|
start_date,
|
||||||
|
weeks_to_rota=weeks_to_rota,
|
||||||
|
)
|
||||||
|
worker1 = Worker(name="worker1", site="group1", grade=1, fte=100)
|
||||||
|
worker2 = Worker(
|
||||||
|
name="worker2", site="group1", grade=1, fte=100,
|
||||||
|
shift_start_dates={"a": datetime.date(2022, 3, 28)},
|
||||||
|
shift_end_dates={"a": datetime.date(2022, 4, 25)},
|
||||||
|
)
|
||||||
|
Rota.add_workers([worker1, worker2])
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.0})
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
# Verify assignments for worker2
|
||||||
|
for week in range(1, weeks_to_rota + 1):
|
||||||
|
val = Rota.model.works[worker2.id, week, "Mon", "a"].value
|
||||||
|
assigned = val is not None and val > 0.5
|
||||||
|
if week < 4 or week >= 8:
|
||||||
|
assert not assigned, f"Worker 2 should not be assigned 'a' in week {week}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_dependent_fte_target_scaling():
|
||||||
|
weeks_to_rota = 10
|
||||||
|
start_date = datetime.date(2022, 3, 7) # Mon
|
||||||
|
|
||||||
|
Rota = RotaBuilder(
|
||||||
|
start_date,
|
||||||
|
weeks_to_rota=weeks_to_rota,
|
||||||
|
)
|
||||||
|
worker1 = Worker(name="worker1", site="group1", grade=1, fte=100)
|
||||||
|
worker2 = Worker(
|
||||||
|
name="worker2", site="group1", grade=1, fte=100,
|
||||||
|
shift_start_dates={"a": datetime.date(2022, 3, 28)},
|
||||||
|
shift_end_dates={"a": datetime.date(2022, 4, 25)},
|
||||||
|
)
|
||||||
|
Rota.add_workers([worker1, worker2])
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Rota.build_and_solve(options={"ratio": 0.0})
|
||||||
|
assert Rota.results.solver.status == "ok"
|
||||||
|
|
||||||
|
# Targets check
|
||||||
|
assert abs(worker1.shift_target_number["a"] - 7.14) < 0.1
|
||||||
|
assert abs(worker2.shift_target_number["a"] - 2.86) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_dependent_dates_validation_invalid_shift():
|
||||||
|
weeks_to_rota = 2
|
||||||
|
start_date = datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
Rota = RotaBuilder(start_date, weeks_to_rota=weeks_to_rota)
|
||||||
|
worker = Worker(
|
||||||
|
name="worker1", site="group1", grade=1, fte=100,
|
||||||
|
shift_start_dates=[ShiftStartDate(shift="non_existent", start_date=datetime.date(2022, 3, 7))]
|
||||||
|
)
|
||||||
|
Rota.add_worker(worker)
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group1",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(InvalidShift):
|
||||||
|
Rota.build_workers()
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_dependent_dates_validation_ineligible_warning():
|
||||||
|
weeks_to_rota = 2
|
||||||
|
start_date = datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
Rota = RotaBuilder(start_date, weeks_to_rota=weeks_to_rota)
|
||||||
|
worker = Worker(
|
||||||
|
name="worker1", site="group1", grade=1, fte=100,
|
||||||
|
shift_start_dates=[ShiftStartDate(shift="a", start_date=datetime.date(2022, 3, 7))]
|
||||||
|
)
|
||||||
|
Rota.add_worker(worker)
|
||||||
|
Rota.add_shifts(
|
||||||
|
SingleShift(
|
||||||
|
sites=("group2",),
|
||||||
|
name="a",
|
||||||
|
length=12.5,
|
||||||
|
days=("Mon",),
|
||||||
|
workers_required=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||||
|
Rota.build_workers()
|
||||||
|
|
||||||
|
warnings = Rota.get_warnings("Worker/ineligible shift date constraint")
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "is not eligible to work it" in warnings[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_shift_dependent_dates_model_definition():
|
||||||
|
worker = Worker(
|
||||||
|
name="worker1", site="group1", grade=1, fte=100,
|
||||||
|
shift_start_dates=[{"shift": "a", "start_date": "2022-03-07"}],
|
||||||
|
shift_end_dates=[{"shift": "a", "end_date": "2022-04-07"}]
|
||||||
|
)
|
||||||
|
assert len(worker.shift_start_dates) == 1
|
||||||
|
assert isinstance(worker.shift_start_dates[0], ShiftStartDate)
|
||||||
|
assert worker.shift_start_dates[0].shift == "a"
|
||||||
|
assert worker.shift_start_dates[0].start_date == datetime.date(2022, 3, 7)
|
||||||
|
|
||||||
|
assert len(worker.shift_end_dates) == 1
|
||||||
|
assert isinstance(worker.shift_end_dates[0], ShiftEndDate)
|
||||||
|
assert worker.shift_end_dates[0].shift == "a"
|
||||||
|
assert worker.shift_end_dates[0].end_date == datetime.date(2022, 4, 7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_time_limit():
|
||||||
|
from gen_proc import parse_time_limit
|
||||||
|
assert parse_time_limit(3600) == 3600
|
||||||
|
assert parse_time_limit("3600") == 3600
|
||||||
|
assert parse_time_limit("10h") == 36000
|
||||||
|
assert parse_time_limit("30m") == 1800
|
||||||
|
assert parse_time_limit("45s") == 45
|
||||||
|
assert parse_time_limit(" 1.5h ") == 5400
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_time_limit("invalid")
|
||||||
Reference in New Issue
Block a user