codex-services 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_services/__init__.py +1 -0
- codex_services/booking/__init__.py +37 -0
- codex_services/booking/_shared/__init__.py +1 -0
- codex_services/booking/_shared/calculator.py +330 -0
- codex_services/booking/_shared/dto.py +82 -0
- codex_services/booking/_shared/exceptions.py +318 -0
- codex_services/booking/_shared/interfaces.py +66 -0
- codex_services/booking/_shared/validators.py +132 -0
- codex_services/booking/room/__init__.py +7 -0
- codex_services/booking/slot_master/__init__.py +58 -0
- codex_services/booking/slot_master/api.py +171 -0
- codex_services/booking/slot_master/chain_finder.py +450 -0
- codex_services/booking/slot_master/dto.py +446 -0
- codex_services/booking/slot_master/modes.py +44 -0
- codex_services/booking/slot_master/scorer.py +168 -0
- codex_services/calendar/__init__.py +5 -0
- codex_services/calendar/engine.py +101 -0
- codex_services/py.typed +0 -0
- codex_services-0.1.0.dist-info/METADATA +113 -0
- codex_services-0.1.0.dist-info/RECORD +21 -0
- codex_services-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_services.booking.slot_master.api
|
|
3
|
+
======================================
|
|
4
|
+
High-level facade for the slot-master booking engine.
|
|
5
|
+
|
|
6
|
+
Use this module if you work with plain Python dicts and don't need ORM integration.
|
|
7
|
+
For Django projects, see :mod:`codex_tools.adapters.django`.
|
|
8
|
+
|
|
9
|
+
Quick start::
|
|
10
|
+
|
|
11
|
+
from codex_services.booking.slot_master.api import find_slots
|
|
12
|
+
|
|
13
|
+
result = find_slots(
|
|
14
|
+
request_data={
|
|
15
|
+
"service_requests": [
|
|
16
|
+
{
|
|
17
|
+
"service_id": "haircut",
|
|
18
|
+
"duration_minutes": 60,
|
|
19
|
+
"possible_resource_ids": ["1", "2"],
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"booking_date": "2024-05-15",
|
|
23
|
+
},
|
|
24
|
+
resources_availability=[
|
|
25
|
+
{
|
|
26
|
+
"resource_id": "1",
|
|
27
|
+
"free_windows": [
|
|
28
|
+
["2024-05-15T09:00:00", "2024-05-15T18:00:00"],
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
# result["solutions"] — list of booking chain dicts
|
|
34
|
+
# result["solutions"][0]["items"][0]["start_time"] — ISO datetime string
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from collections.abc import Callable
|
|
40
|
+
from datetime import date
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from .chain_finder import ChainFinder
|
|
44
|
+
from .dto import BookingEngineRequest, EngineResult, MasterAvailability
|
|
45
|
+
from .scorer import BookingScorer, ScoringWeights
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_availability(items: list[dict[str, Any]]) -> dict[str, MasterAvailability]:
|
|
49
|
+
return {(m := MasterAvailability.model_validate(item)).resource_id: m for item in items}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_slots(
|
|
53
|
+
request_data: dict[str, Any],
|
|
54
|
+
resources_availability: list[dict[str, Any]],
|
|
55
|
+
*,
|
|
56
|
+
step_minutes: int = 30,
|
|
57
|
+
max_solutions: int = 50,
|
|
58
|
+
scoring_weights: dict[str, Any] | None = None,
|
|
59
|
+
preferred_resource_ids: list[str] | None = None,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Find available booking slots for one day.
|
|
62
|
+
|
|
63
|
+
Validates input via Pydantic, runs :class:`ChainFinder`, optionally scores results.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
request_data: Dict matching :class:`~codex_services.booking.slot_master.BookingEngineRequest`.
|
|
67
|
+
Required: ``service_requests``, ``booking_date``.
|
|
68
|
+
resources_availability: List of dicts matching
|
|
69
|
+
:class:`~codex_services.booking.slot_master.MasterAvailability`.
|
|
70
|
+
Each item must contain ``resource_id`` and ``free_windows``.
|
|
71
|
+
step_minutes: Slot grid step in minutes. Defaults to 30.
|
|
72
|
+
max_solutions: Max solutions returned by the engine. Defaults to 50.
|
|
73
|
+
scoring_weights: Optional dict with :class:`~codex_services.booking.slot_master.scorer.ScoringWeights`
|
|
74
|
+
fields to rank solutions. Supported keys: ``preferred_resource_bonus``,
|
|
75
|
+
``same_resource_bonus``, ``min_idle_bonus_per_hour``,
|
|
76
|
+
``early_slot_penalty_per_hour``.
|
|
77
|
+
preferred_resource_ids: Resource IDs to boost in scoring.
|
|
78
|
+
Only effective when ``scoring_weights`` is provided.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Serialised :class:`~codex_services.booking.slot_master.EngineResult` dict.
|
|
82
|
+
Key ``"solutions"`` is a list of booking chain dicts.
|
|
83
|
+
Returns an empty list under ``"solutions"`` if no slots are available.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
pydantic.ValidationError: If ``request_data`` or ``resources_availability``
|
|
87
|
+
do not match the expected schema.
|
|
88
|
+
NotImplementedError: If the request uses an unsupported mode
|
|
89
|
+
(e.g. MULTI_DAY or group bookings).
|
|
90
|
+
|
|
91
|
+
Example::
|
|
92
|
+
|
|
93
|
+
result = find_slots(
|
|
94
|
+
request_data={
|
|
95
|
+
"service_requests": [
|
|
96
|
+
{"service_id": "s1", "duration_minutes": 60,
|
|
97
|
+
"possible_resource_ids": ["m1"]},
|
|
98
|
+
],
|
|
99
|
+
"booking_date": "2024-05-15",
|
|
100
|
+
},
|
|
101
|
+
resources_availability=[
|
|
102
|
+
{"resource_id": "m1",
|
|
103
|
+
"free_windows": [["2024-05-15T09:00:00", "2024-05-15T18:00:00"]]},
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
for chain in result["solutions"]:
|
|
107
|
+
print(chain["items"][0]["start_time"])
|
|
108
|
+
"""
|
|
109
|
+
request = BookingEngineRequest.model_validate(request_data)
|
|
110
|
+
availability = _parse_availability(resources_availability)
|
|
111
|
+
|
|
112
|
+
result: EngineResult = ChainFinder(step_minutes).find(request, availability, max_solutions=max_solutions)
|
|
113
|
+
|
|
114
|
+
if scoring_weights is not None:
|
|
115
|
+
weights = ScoringWeights(**scoring_weights)
|
|
116
|
+
scorer = BookingScorer(weights=weights, preferred_resource_ids=preferred_resource_ids)
|
|
117
|
+
result = scorer.score(result)
|
|
118
|
+
|
|
119
|
+
return result.model_dump(mode="json")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def find_nearest_slots(
|
|
123
|
+
request_data: dict[str, Any],
|
|
124
|
+
get_availability_fn: Callable[[date], list[dict[str, Any]]],
|
|
125
|
+
*,
|
|
126
|
+
search_from: date | None = None,
|
|
127
|
+
search_days: int = 60,
|
|
128
|
+
step_minutes: int = 30,
|
|
129
|
+
) -> dict[str, Any] | None:
|
|
130
|
+
"""Search for the nearest available booking slots across multiple days.
|
|
131
|
+
|
|
132
|
+
Iterates days starting from *search_from* until a day with solutions is found.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
request_data: Dict matching :class:`~codex_services.booking.slot_master.BookingEngineRequest`.
|
|
136
|
+
get_availability_fn: Callable ``(date) -> list[dict]``. Called for each checked
|
|
137
|
+
day. Each dict in the returned list must match
|
|
138
|
+
:class:`~codex_services.booking.slot_master.MasterAvailability`.
|
|
139
|
+
search_from: Start date (inclusive). Defaults to :func:`~datetime.date.today`.
|
|
140
|
+
search_days: Max number of days to search. Defaults to 60.
|
|
141
|
+
step_minutes: Slot grid step in minutes. Defaults to 30.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Serialised :class:`~codex_services.booking.slot_master.EngineResult` dict for the first
|
|
145
|
+
matching day, or ``None`` if no slots were found within *search_days*.
|
|
146
|
+
|
|
147
|
+
Example::
|
|
148
|
+
|
|
149
|
+
def get_availability(d):
|
|
150
|
+
# return your per-day availability data as list of dicts
|
|
151
|
+
return [...]
|
|
152
|
+
|
|
153
|
+
result = find_nearest_slots(
|
|
154
|
+
request_data={...},
|
|
155
|
+
get_availability_fn=get_availability,
|
|
156
|
+
search_from=date.today(),
|
|
157
|
+
)
|
|
158
|
+
if result:
|
|
159
|
+
print(result["solutions"][0]["items"][0]["start_time"])
|
|
160
|
+
"""
|
|
161
|
+
search_from = search_from or date.today()
|
|
162
|
+
request = BookingEngineRequest.model_validate(request_data)
|
|
163
|
+
|
|
164
|
+
def _get_avail(d: date) -> dict[str, MasterAvailability]:
|
|
165
|
+
return _parse_availability(get_availability_fn(d))
|
|
166
|
+
|
|
167
|
+
result = ChainFinder(step_minutes).find_nearest(
|
|
168
|
+
request, _get_avail, search_from=search_from, search_days=search_days
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return result.model_dump(mode="json") if result.has_solutions else None
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_services.booking.slot_master.chain_finder
|
|
3
|
+
================================================
|
|
4
|
+
Main algorithm for finding slot chains for N services.
|
|
5
|
+
|
|
6
|
+
Framework-agnostic (does not depend on Django/ORM) -- works only with DTOs and datetime.
|
|
7
|
+
Acts as an orchestrator for SlotCalculator and BookingValidator.
|
|
8
|
+
|
|
9
|
+
Performance principle:
|
|
10
|
+
Inside recursive search (backtracking) we use lightweight _SlotCandidate
|
|
11
|
+
with __slots__ -- without Pydantic validation. Pydantic objects (SingleServiceSolution,
|
|
12
|
+
BookingChainSolution) are created ONLY once for each found solution.
|
|
13
|
+
This is critical with a large number of services and resources.
|
|
14
|
+
|
|
15
|
+
Imports:
|
|
16
|
+
from codex_services.booking.slot_master import ChainFinder
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from datetime import date, datetime, timedelta
|
|
21
|
+
|
|
22
|
+
from codex_services.booking._shared.calculator import SlotCalculator
|
|
23
|
+
|
|
24
|
+
from .dto import (
|
|
25
|
+
BookingChainSolution,
|
|
26
|
+
BookingEngineRequest,
|
|
27
|
+
EngineResult,
|
|
28
|
+
MasterAvailability,
|
|
29
|
+
SingleServiceSolution,
|
|
30
|
+
)
|
|
31
|
+
from .modes import BookingMode
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _SlotCandidate:
|
|
35
|
+
"""
|
|
36
|
+
Lightweight internal representation of a booked slot inside backtracking.
|
|
37
|
+
|
|
38
|
+
Uses __slots__ for minimal memory consumption and fast access.
|
|
39
|
+
Does NOT contain Pydantic validation -- only raw data.
|
|
40
|
+
|
|
41
|
+
Converted to SingleServiceSolution (Pydantic) only for the final result.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
__slots__ = (
|
|
45
|
+
"service_id",
|
|
46
|
+
"resource_id",
|
|
47
|
+
"start_time",
|
|
48
|
+
"end_time",
|
|
49
|
+
"gap_end_time",
|
|
50
|
+
"parallel_group",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
service_id: str,
|
|
56
|
+
resource_id: str,
|
|
57
|
+
start_time: datetime,
|
|
58
|
+
end_time: datetime,
|
|
59
|
+
gap_end_time: datetime,
|
|
60
|
+
parallel_group: str | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.service_id = service_id
|
|
63
|
+
self.resource_id = resource_id
|
|
64
|
+
self.start_time = start_time
|
|
65
|
+
self.end_time = end_time
|
|
66
|
+
self.gap_end_time = gap_end_time
|
|
67
|
+
self.parallel_group = parallel_group
|
|
68
|
+
|
|
69
|
+
def to_solution(self) -> SingleServiceSolution:
|
|
70
|
+
"""Converts to Pydantic DTO for the final result."""
|
|
71
|
+
return SingleServiceSolution(
|
|
72
|
+
service_id=self.service_id,
|
|
73
|
+
resource_id=self.resource_id,
|
|
74
|
+
start_time=self.start_time,
|
|
75
|
+
end_time=self.end_time,
|
|
76
|
+
gap_end_time=self.gap_end_time,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ChainFinder:
|
|
81
|
+
"""
|
|
82
|
+
Finds combinations of time slots for N services (booking chains).
|
|
83
|
+
|
|
84
|
+
Core algorithm: recursive backtracking.
|
|
85
|
+
Iterates over possible resources and their free windows for each service.
|
|
86
|
+
Checks for the absence of conflicts with already assigned services in the chain.
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
1 service, any free resource:
|
|
90
|
+
```python
|
|
91
|
+
finder = ChainFinder(step_minutes=30)
|
|
92
|
+
request = BookingEngineRequest(
|
|
93
|
+
service_requests=[
|
|
94
|
+
ServiceRequest(service_id="5", duration_minutes=60,
|
|
95
|
+
possible_resource_ids=["1", "2"])
|
|
96
|
+
],
|
|
97
|
+
booking_date=date(2024, 5, 10),
|
|
98
|
+
mode=BookingMode.SINGLE_DAY,
|
|
99
|
+
)
|
|
100
|
+
result = finder.find(request, resources_availability)
|
|
101
|
+
# result.solutions -- list of BookingChainSolution
|
|
102
|
+
# result.get_unique_start_times() -> ["09:00", "09:30", "10:00", ...]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
2 services on the same day:
|
|
106
|
+
```python
|
|
107
|
+
request = BookingEngineRequest(
|
|
108
|
+
service_requests=[svc_task_1, svc_task_2],
|
|
109
|
+
booking_date=date(2024, 5, 10),
|
|
110
|
+
mode=BookingMode.SINGLE_DAY,
|
|
111
|
+
)
|
|
112
|
+
result = finder.find(request, availability)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Booking a specific resource (RESOURCE_LOCKED):
|
|
116
|
+
```python
|
|
117
|
+
# Just pass possible_resource_ids=[locked_resource_id]
|
|
118
|
+
# and mode=BookingMode.RESOURCE_LOCKED
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
step_minutes: Grid slot step (30 min by default).
|
|
123
|
+
min_start: Minimum acceptable start time of the first service.
|
|
124
|
+
None = no restriction (e.g., in tests).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
step_minutes: int = 30,
|
|
130
|
+
min_start: datetime | None = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
self.step_minutes = step_minutes
|
|
133
|
+
self.min_start = min_start
|
|
134
|
+
self._calc = SlotCalculator(step_minutes)
|
|
135
|
+
|
|
136
|
+
def find(
|
|
137
|
+
self,
|
|
138
|
+
request: BookingEngineRequest,
|
|
139
|
+
resources_availability: dict[str, MasterAvailability],
|
|
140
|
+
max_solutions: int = 50,
|
|
141
|
+
max_unique_starts: int | None = None,
|
|
142
|
+
) -> EngineResult:
|
|
143
|
+
"""
|
|
144
|
+
Unified engine entry point. Delegates to the appropriate mode.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
request: Input request with services, date, and mode.
|
|
148
|
+
resources_availability: Dictionary {resource_id: MasterAvailability}.
|
|
149
|
+
Usually prepared by an AvailabilityAdapter.
|
|
150
|
+
Keys -- strings (resource identifiers).
|
|
151
|
+
max_solutions: Maximum number of options the engine will return.
|
|
152
|
+
Does not affect correctness -- only completeness.
|
|
153
|
+
max_unique_starts: Stop after finding N unique start times
|
|
154
|
+
(based on items[0].start_time).
|
|
155
|
+
None = no limit.
|
|
156
|
+
Example: max_unique_starts=8 -> only the closest 8 slots,
|
|
157
|
+
even if there are 16 in a day. Halves engine iterations.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
EngineResult with found solutions.
|
|
161
|
+
solutions sorted by the start time of the first service.
|
|
162
|
+
"""
|
|
163
|
+
# Fail Fast: Check for unsupported features
|
|
164
|
+
if request.group_size > 1:
|
|
165
|
+
raise NotImplementedError("Group bookings (group_size > 1) are not yet supported.")
|
|
166
|
+
|
|
167
|
+
if request.mode == BookingMode.MULTI_DAY:
|
|
168
|
+
raise NotImplementedError("MULTI_DAY booking mode is not yet implemented.")
|
|
169
|
+
|
|
170
|
+
if request.mode in (BookingMode.SINGLE_DAY, BookingMode.RESOURCE_LOCKED):
|
|
171
|
+
solutions = self._find_single_day(request, resources_availability, max_solutions, max_unique_starts)
|
|
172
|
+
else:
|
|
173
|
+
solutions = [] # pragma: no cover
|
|
174
|
+
|
|
175
|
+
solutions.sort(key=lambda s: s.starts_at)
|
|
176
|
+
return EngineResult(mode=request.mode, solutions=solutions)
|
|
177
|
+
|
|
178
|
+
def find_nearest(
|
|
179
|
+
self,
|
|
180
|
+
request: BookingEngineRequest,
|
|
181
|
+
get_availability_for_date: Callable[[date], dict[str, MasterAvailability]],
|
|
182
|
+
search_from: date,
|
|
183
|
+
search_days: int = 60,
|
|
184
|
+
max_solutions_per_day: int = 1,
|
|
185
|
+
) -> EngineResult:
|
|
186
|
+
"""
|
|
187
|
+
Searches for the first day with available slots in the search_days range.
|
|
188
|
+
|
|
189
|
+
Used for:
|
|
190
|
+
- Rebooking: resource is sick -> find a new date for N appointments
|
|
191
|
+
- Waitlist: closest free slot to notify the client
|
|
192
|
+
- MULTI_DAY planning: find the first day the chain fits
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
request: Request (booking_date will be replaced for each checked day).
|
|
196
|
+
get_availability_for_date: callable(date) -> dict[str, MasterAvailability].
|
|
197
|
+
Called for each checked day.
|
|
198
|
+
Wraps DjangoAvailabilityAdapter in the Django layer.
|
|
199
|
+
search_from: Date to start the search from (inclusive).
|
|
200
|
+
search_days: Maximum days to check. Defaults to 60.
|
|
201
|
+
max_solutions_per_day: How many solutions to look for per day.
|
|
202
|
+
1 = fast mode (stop at the first one).
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
EngineResult of the first day with solutions.
|
|
206
|
+
If nothing found in search_days — EngineResult(solutions=[]).
|
|
207
|
+
|
|
208
|
+
Example (Django layer):
|
|
209
|
+
adapter = DjangoAvailabilityAdapter()
|
|
210
|
+
resource_ids = [...]
|
|
211
|
+
|
|
212
|
+
def get_avail(d):
|
|
213
|
+
return adapter.build_resources_availability(resource_ids, d)
|
|
214
|
+
|
|
215
|
+
result = finder.find_nearest(request, get_avail, search_from=date.today())
|
|
216
|
+
if result.has_solutions:
|
|
217
|
+
print(result.best.starts_at) # date and time of the new slot
|
|
218
|
+
"""
|
|
219
|
+
for offset in range(search_days):
|
|
220
|
+
check_date = search_from + timedelta(days=offset)
|
|
221
|
+
|
|
222
|
+
# Update date in the request (frozen=True -> model_copy)
|
|
223
|
+
day_request = request.model_copy(update={"booking_date": check_date})
|
|
224
|
+
|
|
225
|
+
availability = get_availability_for_date(check_date)
|
|
226
|
+
if not availability:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
result = self.find(day_request, availability, max_solutions=max_solutions_per_day)
|
|
230
|
+
if result.has_solutions:
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
return EngineResult(mode=request.mode, solutions=[])
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# SINGLE_DAY / RESOURCE_LOCKED mode
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def _find_single_day(
|
|
240
|
+
self,
|
|
241
|
+
request: BookingEngineRequest,
|
|
242
|
+
resources_availability: dict[str, MasterAvailability],
|
|
243
|
+
max_solutions: int,
|
|
244
|
+
max_unique_starts: int | None = None,
|
|
245
|
+
) -> list[BookingChainSolution]:
|
|
246
|
+
"""
|
|
247
|
+
Search for a chain for the 'all services in one day' mode.
|
|
248
|
+
|
|
249
|
+
Performance:
|
|
250
|
+
Internally works with _SlotCandidate (__slots__) -- without Pydantic.
|
|
251
|
+
Pydantic objects are created ONLY for final solutions.
|
|
252
|
+
With 3 services x 3 resources x 20 slots -- up to 1800 iterations.
|
|
253
|
+
|
|
254
|
+
Request parameters considered in this method:
|
|
255
|
+
request.overlap_allowed:
|
|
256
|
+
True -> different resources work independently (can be parallel).
|
|
257
|
+
False -> each subsequent service starts only after the previous one ends.
|
|
258
|
+
request.max_chain_duration_minutes:
|
|
259
|
+
If set — cuts off backtracking branches where the chain already exceeds the limit.
|
|
260
|
+
"""
|
|
261
|
+
solutions: list[BookingChainSolution] = []
|
|
262
|
+
chain: list[_SlotCandidate] = []
|
|
263
|
+
seen_starts: set[str] = set() # unique start times of the first service
|
|
264
|
+
|
|
265
|
+
def backtrack(service_index: int) -> None:
|
|
266
|
+
if len(solutions) >= max_solutions:
|
|
267
|
+
return
|
|
268
|
+
if max_unique_starts is not None and len(seen_starts) >= max_unique_starts:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
if service_index >= len(request.service_requests):
|
|
272
|
+
# Final check + conversion to Pydantic only here
|
|
273
|
+
if self._no_conflicts_fast(chain):
|
|
274
|
+
solution = BookingChainSolution(items=[c.to_solution() for c in chain])
|
|
275
|
+
solutions.append(solution)
|
|
276
|
+
seen_starts.add(chain[0].start_time.strftime("%H:%M"))
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
service_req = request.service_requests[service_index]
|
|
280
|
+
duration_delta = timedelta(minutes=service_req.duration_minutes)
|
|
281
|
+
gap_delta = timedelta(minutes=service_req.min_gap_after_minutes)
|
|
282
|
+
|
|
283
|
+
# --- Parallel Group Logic ---
|
|
284
|
+
# If the current service has a parallel_group, look for a "partner" in the already assembled chain.
|
|
285
|
+
# If found, the start time must strictly match.
|
|
286
|
+
forced_start_time: datetime | None = None
|
|
287
|
+
if service_req.parallel_group:
|
|
288
|
+
for item in chain:
|
|
289
|
+
if item.parallel_group == service_req.parallel_group:
|
|
290
|
+
forced_start_time = item.start_time
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
for resource_id in service_req.possible_resource_ids:
|
|
294
|
+
availability = resources_availability.get(resource_id)
|
|
295
|
+
if not availability:
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
# Busy intervals of this resource in the current chain
|
|
299
|
+
resource_busy = [(c.start_time, c.gap_end_time) for c in chain if c.resource_id == resource_id]
|
|
300
|
+
|
|
301
|
+
# If there is a forced_start_time, check only it
|
|
302
|
+
if forced_start_time:
|
|
303
|
+
# Check if the resource is free at this specific time
|
|
304
|
+
slot_end = forced_start_time + duration_delta
|
|
305
|
+
gap_end = slot_end + gap_delta
|
|
306
|
+
|
|
307
|
+
# 1. Check resource's occupancy in the chain
|
|
308
|
+
if not self._is_slot_free_fast(forced_start_time, gap_end, resource_busy):
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
# 2. Check if it falls into the resource's free windows
|
|
312
|
+
in_window = False
|
|
313
|
+
for w_start, w_end in availability.free_windows:
|
|
314
|
+
if w_start <= forced_start_time and w_end >= slot_end:
|
|
315
|
+
in_window = True
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
if not in_window:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
# If everything is ok - add and move on
|
|
322
|
+
chain.append(
|
|
323
|
+
_SlotCandidate(
|
|
324
|
+
service_id=service_req.service_id,
|
|
325
|
+
resource_id=resource_id,
|
|
326
|
+
start_time=forced_start_time,
|
|
327
|
+
end_time=slot_end,
|
|
328
|
+
gap_end_time=gap_end,
|
|
329
|
+
parallel_group=service_req.parallel_group,
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
backtrack(service_index + 1)
|
|
333
|
+
chain.pop()
|
|
334
|
+
continue # Move to the next resource, no need to iterate over slots
|
|
335
|
+
|
|
336
|
+
# --- Standard Logic (No forced start time) ---
|
|
337
|
+
effective_min = self._effective_min_start(resource_busy, availability.buffer_between_minutes)
|
|
338
|
+
|
|
339
|
+
# overlap_allowed=False: each service starts after the end of all previous ones
|
|
340
|
+
if not request.overlap_allowed and chain:
|
|
341
|
+
chain_ends_at = max(c.end_time for c in chain)
|
|
342
|
+
if effective_min is None or effective_min < chain_ends_at:
|
|
343
|
+
effective_min = chain_ends_at
|
|
344
|
+
|
|
345
|
+
# --- Anchor Logic ---
|
|
346
|
+
# Use anchor only for the first service or if services are parallel.
|
|
347
|
+
# For 2nd+ service in a sequential chain, anchor is None (tight fit).
|
|
348
|
+
use_anchor = availability.work_start if (not chain or request.overlap_allowed) else None
|
|
349
|
+
|
|
350
|
+
for window_start, window_end in availability.free_windows:
|
|
351
|
+
slots = self._calc.find_slots_in_window(
|
|
352
|
+
window_start=window_start,
|
|
353
|
+
window_end=window_end,
|
|
354
|
+
duration_minutes=service_req.duration_minutes,
|
|
355
|
+
min_start=effective_min,
|
|
356
|
+
grid_anchor=use_anchor,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
for slot_start in slots:
|
|
360
|
+
if len(solutions) >= max_solutions:
|
|
361
|
+
return
|
|
362
|
+
if max_unique_starts is not None and len(seen_starts) >= max_unique_starts:
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
slot_end = slot_start + duration_delta
|
|
366
|
+
gap_end = slot_end + gap_delta
|
|
367
|
+
|
|
368
|
+
# Simple datetime comparison -- without Pydantic
|
|
369
|
+
if not self._is_slot_free_fast(slot_start, gap_end, resource_busy): # pragma: no cover
|
|
370
|
+
continue # pragma: no cover
|
|
371
|
+
|
|
372
|
+
# Check maximum chain duration
|
|
373
|
+
if request.max_chain_duration_minutes is not None and chain:
|
|
374
|
+
chain_start = min(c.start_time for c in chain)
|
|
375
|
+
prospective_span = int(
|
|
376
|
+
(max(slot_end, max(c.end_time for c in chain)) - chain_start).total_seconds() / 60
|
|
377
|
+
)
|
|
378
|
+
if prospective_span > request.max_chain_duration_minutes:
|
|
379
|
+
continue # cut off branch - chain is too long
|
|
380
|
+
|
|
381
|
+
chain.append(
|
|
382
|
+
_SlotCandidate(
|
|
383
|
+
service_id=service_req.service_id,
|
|
384
|
+
resource_id=resource_id,
|
|
385
|
+
start_time=slot_start,
|
|
386
|
+
end_time=slot_end,
|
|
387
|
+
gap_end_time=gap_end,
|
|
388
|
+
parallel_group=service_req.parallel_group,
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
backtrack(service_index + 1)
|
|
392
|
+
chain.pop()
|
|
393
|
+
|
|
394
|
+
backtrack(0)
|
|
395
|
+
return solutions
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
# Fast internal checks (without Pydantic -- stdlib only)
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def _is_slot_free_fast(
|
|
403
|
+
slot_start: datetime,
|
|
404
|
+
slot_end: datetime,
|
|
405
|
+
busy_intervals: list[tuple[datetime, datetime]],
|
|
406
|
+
) -> bool:
|
|
407
|
+
"""Fast slot availability check. Without Pydantic."""
|
|
408
|
+
return all(not (slot_start < b_end and slot_end > b_start) for b_start, b_end in busy_intervals)
|
|
409
|
+
|
|
410
|
+
@staticmethod
|
|
411
|
+
def _no_conflicts_fast(chain: list["_SlotCandidate"]) -> bool:
|
|
412
|
+
"""Final check of the chain for resource conflicts. Without Pydantic."""
|
|
413
|
+
by_resource: dict[str, list[_SlotCandidate]] = {}
|
|
414
|
+
for c in chain:
|
|
415
|
+
if c.resource_id not in by_resource:
|
|
416
|
+
by_resource[c.resource_id] = []
|
|
417
|
+
by_resource[c.resource_id].append(c)
|
|
418
|
+
|
|
419
|
+
for slots in by_resource.values():
|
|
420
|
+
if len(slots) < 2:
|
|
421
|
+
continue
|
|
422
|
+
sorted_slots = sorted(slots, key=lambda s: s.start_time)
|
|
423
|
+
for i in range(len(sorted_slots) - 1):
|
|
424
|
+
if sorted_slots[i + 1].start_time < sorted_slots[i].gap_end_time: # pragma: no cover
|
|
425
|
+
return False # pragma: no cover
|
|
426
|
+
|
|
427
|
+
return True
|
|
428
|
+
|
|
429
|
+
def _effective_min_start(
|
|
430
|
+
self,
|
|
431
|
+
resource_busy: list[tuple[datetime, datetime]],
|
|
432
|
+
buffer_minutes: int,
|
|
433
|
+
) -> datetime | None:
|
|
434
|
+
"""
|
|
435
|
+
Minimum allowable start time for the resource.
|
|
436
|
+
|
|
437
|
+
Considers:
|
|
438
|
+
- self.min_start (global -- "no earlier than N mins from now")
|
|
439
|
+
- End of the last booked slot + buffer between clients
|
|
440
|
+
"""
|
|
441
|
+
candidates: list[datetime] = []
|
|
442
|
+
|
|
443
|
+
if self.min_start:
|
|
444
|
+
candidates.append(self.min_start)
|
|
445
|
+
|
|
446
|
+
if resource_busy:
|
|
447
|
+
last_end = max(end for _, end in resource_busy)
|
|
448
|
+
candidates.append(last_end + timedelta(minutes=buffer_minutes))
|
|
449
|
+
|
|
450
|
+
return max(candidates) if candidates else None
|