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,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_services.booking._shared.exceptions
|
|
3
|
+
==========================================
|
|
4
|
+
Custom exceptions for the booking engine.
|
|
5
|
+
|
|
6
|
+
Hierarchy:
|
|
7
|
+
BookingEngineError (base)
|
|
8
|
+
├── NoAvailabilityError — no free slots for the request
|
|
9
|
+
├── InvalidServiceDurationError — incorrect service duration
|
|
10
|
+
├── InvalidBookingDateError — incorrect/invalid date
|
|
11
|
+
├── ResourceNotAvailableError — specific resource is not available
|
|
12
|
+
├── SlotAlreadyBookedError — race condition: slot was booked by another user
|
|
13
|
+
└── ChainBuildError — chain was partially assembled but not completed
|
|
14
|
+
|
|
15
|
+
Usage in the service layer:
|
|
16
|
+
from codex_services.booking._shared.exceptions import NoAvailabilityError
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
result = finder.find(request, availability)
|
|
20
|
+
if not result.has_solutions:
|
|
21
|
+
raise NoAvailabilityError(
|
|
22
|
+
date=request.booking_date,
|
|
23
|
+
service_ids=[s.service_id for s in request.service_requests],
|
|
24
|
+
)
|
|
25
|
+
except NoAvailabilityError as e:
|
|
26
|
+
# Django view will catch and return a user-friendly message
|
|
27
|
+
return render(request, "booking/no_slots.html", {"error": str(e)})
|
|
28
|
+
|
|
29
|
+
Imports:
|
|
30
|
+
from codex_services.booking._shared.exceptions import (
|
|
31
|
+
BookingEngineError,
|
|
32
|
+
NoAvailabilityError,
|
|
33
|
+
InvalidServiceDurationError,
|
|
34
|
+
InvalidBookingDateError,
|
|
35
|
+
ResourceNotAvailableError,
|
|
36
|
+
)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from datetime import date
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BookingEngineError(Exception):
|
|
43
|
+
"""
|
|
44
|
+
Base exception of the booking engine.
|
|
45
|
+
|
|
46
|
+
All other exceptions inherit from it.
|
|
47
|
+
A Django view can catch BookingEngineError for unified handling
|
|
48
|
+
of all engine errors.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
try:
|
|
52
|
+
result = service.book(...)
|
|
53
|
+
except BookingEngineError as e:
|
|
54
|
+
messages.error(request, str(e))
|
|
55
|
+
return redirect("booking:wizard")
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
default_message: str = "Booking system error"
|
|
59
|
+
|
|
60
|
+
def __init__(self, message: str | None = None) -> None:
|
|
61
|
+
super().__init__(message or self.default_message)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NoAvailabilityError(BookingEngineError):
|
|
65
|
+
"""
|
|
66
|
+
The engine found no schedule options for the request.
|
|
67
|
+
|
|
68
|
+
Raised when ChainFinder.find() returns an empty EngineResult.
|
|
69
|
+
Translated to a user-friendly message in the Django view.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
booking_date: Date searched for.
|
|
73
|
+
service_ids: List of service IDs from the request.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
raise NoAvailabilityError(
|
|
77
|
+
booking_date=date(2024, 5, 10),
|
|
78
|
+
service_ids=["5", "12"],
|
|
79
|
+
)
|
|
80
|
+
# str(e) -> "No free slots on 10.05.2024 for the selected services."
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
default_message = "Unfortunately, there are no available slots for these services on the selected date."
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
booking_date: date | None = None,
|
|
88
|
+
service_ids: list[str] | None = None,
|
|
89
|
+
message: str | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self.booking_date = booking_date
|
|
92
|
+
self.service_ids = service_ids or []
|
|
93
|
+
|
|
94
|
+
if message:
|
|
95
|
+
final_message = message
|
|
96
|
+
elif booking_date:
|
|
97
|
+
date_str = booking_date.strftime("%d.%m.%Y")
|
|
98
|
+
final_message = f"No free slots on {date_str} for the selected services. Please try choosing another date."
|
|
99
|
+
else:
|
|
100
|
+
final_message = self.default_message
|
|
101
|
+
|
|
102
|
+
super().__init__(final_message)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class InvalidServiceDurationError(BookingEngineError):
|
|
106
|
+
"""
|
|
107
|
+
Incorrect service duration.
|
|
108
|
+
|
|
109
|
+
Raised if duration_minutes <= 0 or exceeds the explicit maximum.
|
|
110
|
+
|
|
111
|
+
Attributes:
|
|
112
|
+
service_id: ID of the problematic service.
|
|
113
|
+
duration_minutes: The passed duration value.
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
raise InvalidServiceDurationError(service_id="5", duration_minutes=0)
|
|
117
|
+
# str(e) -> "Service 5: incorrect duration 0 min."
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
default_message = "Incorrect service duration."
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
service_id: str | None = None,
|
|
125
|
+
duration_minutes: int | None = None,
|
|
126
|
+
message: str | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
self.service_id = service_id
|
|
129
|
+
self.duration_minutes = duration_minutes
|
|
130
|
+
|
|
131
|
+
if message:
|
|
132
|
+
final_message = message
|
|
133
|
+
elif service_id is not None and duration_minutes is not None:
|
|
134
|
+
final_message = (
|
|
135
|
+
f"Service {service_id}: incorrect duration {duration_minutes} min. Duration must be greater than 0."
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
final_message = self.default_message
|
|
139
|
+
|
|
140
|
+
super().__init__(final_message)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class InvalidBookingDateError(BookingEngineError):
|
|
144
|
+
"""
|
|
145
|
+
Booking date is invalid.
|
|
146
|
+
|
|
147
|
+
Example reasons:
|
|
148
|
+
- Date in the past
|
|
149
|
+
- Date beyond max_advance_days
|
|
150
|
+
- Salon is closed on this day
|
|
151
|
+
|
|
152
|
+
Attributes:
|
|
153
|
+
booking_date: Problematic date.
|
|
154
|
+
reason: Human-readable explanation.
|
|
155
|
+
|
|
156
|
+
Example:
|
|
157
|
+
raise InvalidBookingDateError(
|
|
158
|
+
booking_date=date(2020, 1, 1),
|
|
159
|
+
reason="Date in the past",
|
|
160
|
+
)
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
default_message = "The selected date is unavailable for booking."
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
booking_date: date | None = None,
|
|
168
|
+
reason: str | None = None,
|
|
169
|
+
message: str | None = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
self.booking_date = booking_date
|
|
172
|
+
self.reason = reason
|
|
173
|
+
|
|
174
|
+
if message:
|
|
175
|
+
final_message = message
|
|
176
|
+
elif booking_date and reason:
|
|
177
|
+
date_str = booking_date.strftime("%d.%m.%Y")
|
|
178
|
+
final_message = f"Date {date_str} is unavailable: {reason}."
|
|
179
|
+
elif booking_date:
|
|
180
|
+
date_str = booking_date.strftime("%d.%m.%Y")
|
|
181
|
+
final_message = f"Date {date_str} is unavailable for booking."
|
|
182
|
+
else:
|
|
183
|
+
final_message = self.default_message
|
|
184
|
+
|
|
185
|
+
super().__init__(final_message)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class SlotAlreadyBookedError(BookingEngineError):
|
|
189
|
+
"""
|
|
190
|
+
Slot was free during display but became booked by the time of confirmation.
|
|
191
|
+
|
|
192
|
+
Race condition: client A and client B are viewing the same slot simultaneously.
|
|
193
|
+
A clicks "Book" first -> B receives this error.
|
|
194
|
+
|
|
195
|
+
Attributes:
|
|
196
|
+
resource_id: Resource ID.
|
|
197
|
+
service_id: Service ID.
|
|
198
|
+
booking_date: Booking date.
|
|
199
|
+
slot_time: Selected time (string "HH:MM").
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
raise SlotAlreadyBookedError(
|
|
203
|
+
resource_id="3",
|
|
204
|
+
service_id="5",
|
|
205
|
+
booking_date=date(2024, 5, 10),
|
|
206
|
+
slot_time="14:00",
|
|
207
|
+
)
|
|
208
|
+
# str(e) -> "Slot 14:00 on 10.05.2024 was booked. Please choose another time."
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
default_message = "The selected slot was booked. Please choose another time."
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
resource_id: str | None = None,
|
|
216
|
+
service_id: str | None = None,
|
|
217
|
+
booking_date: date | None = None,
|
|
218
|
+
slot_time: str | None = None,
|
|
219
|
+
message: str | None = None,
|
|
220
|
+
) -> None:
|
|
221
|
+
self.resource_id = resource_id
|
|
222
|
+
self.service_id = service_id
|
|
223
|
+
self.booking_date = booking_date
|
|
224
|
+
self.slot_time = slot_time
|
|
225
|
+
|
|
226
|
+
if message:
|
|
227
|
+
final_message = message
|
|
228
|
+
elif slot_time and booking_date:
|
|
229
|
+
date_str = booking_date.strftime("%d.%m.%Y")
|
|
230
|
+
final_message = (
|
|
231
|
+
f"Slot {slot_time} on {date_str} was booked while you were processing. Please choose another time."
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
final_message = self.default_message
|
|
235
|
+
|
|
236
|
+
super().__init__(final_message)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class ChainBuildError(BookingEngineError):
|
|
240
|
+
"""
|
|
241
|
+
The engine could not assemble a chain for all services in the request.
|
|
242
|
+
|
|
243
|
+
Differs from NoAvailabilityError: here the chain was PARTIALLY assembled,
|
|
244
|
+
but not to the end (constraint violation, incompatible services, etc.).
|
|
245
|
+
|
|
246
|
+
Used when:
|
|
247
|
+
- max_chain_duration_minutes is exceeded
|
|
248
|
+
- Services are incompatible (future: excludes/tags)
|
|
249
|
+
- group_size > available parallel slots
|
|
250
|
+
|
|
251
|
+
Attributes:
|
|
252
|
+
failed_at_index: Index of the service (in service_requests) where assembly failed.
|
|
253
|
+
reason: Technical reason (for logs).
|
|
254
|
+
|
|
255
|
+
Example:
|
|
256
|
+
raise ChainBuildError(
|
|
257
|
+
failed_at_index=2,
|
|
258
|
+
reason="max_chain_duration_minutes=180 exceeded at service 'Coloring'",
|
|
259
|
+
)
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
default_message = "Could not find a schedule for all selected services."
|
|
263
|
+
|
|
264
|
+
def __init__(
|
|
265
|
+
self,
|
|
266
|
+
failed_at_index: int | None = None,
|
|
267
|
+
reason: str | None = None,
|
|
268
|
+
message: str | None = None,
|
|
269
|
+
) -> None:
|
|
270
|
+
self.failed_at_index = failed_at_index
|
|
271
|
+
self.reason = reason
|
|
272
|
+
|
|
273
|
+
if message:
|
|
274
|
+
final_message = message
|
|
275
|
+
elif reason:
|
|
276
|
+
final_message = f"Could not assemble the chain: {reason}."
|
|
277
|
+
else:
|
|
278
|
+
final_message = self.default_message
|
|
279
|
+
|
|
280
|
+
super().__init__(final_message)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class ResourceNotAvailableError(BookingEngineError):
|
|
284
|
+
"""
|
|
285
|
+
Specific resource is not available for booking.
|
|
286
|
+
|
|
287
|
+
Used in RESOURCE_LOCKED mode when the selected resource
|
|
288
|
+
does not work on this day or their schedule is empty.
|
|
289
|
+
|
|
290
|
+
Attributes:
|
|
291
|
+
resource_id: Resource's ID.
|
|
292
|
+
booking_date: Date attempted to book.
|
|
293
|
+
|
|
294
|
+
Example:
|
|
295
|
+
raise ResourceNotAvailableError(resource_id="3", booking_date=date(2024,5,10))
|
|
296
|
+
# str(e) -> "Resource unavailable on 10.05.2024."
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
default_message = "The selected resource is unavailable on this date."
|
|
300
|
+
|
|
301
|
+
def __init__(
|
|
302
|
+
self,
|
|
303
|
+
resource_id: str | None = None,
|
|
304
|
+
booking_date: date | None = None,
|
|
305
|
+
message: str | None = None,
|
|
306
|
+
) -> None:
|
|
307
|
+
self.resource_id = resource_id
|
|
308
|
+
self.booking_date = booking_date
|
|
309
|
+
|
|
310
|
+
if message:
|
|
311
|
+
final_message = message
|
|
312
|
+
elif booking_date:
|
|
313
|
+
date_str = booking_date.strftime("%d.%m.%Y")
|
|
314
|
+
final_message = f"Resource unavailable on {date_str}. Please try another date."
|
|
315
|
+
else:
|
|
316
|
+
final_message = self.default_message
|
|
317
|
+
|
|
318
|
+
super().__init__(final_message)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_services.booking._shared.interfaces
|
|
3
|
+
==========================================
|
|
4
|
+
Protocol contracts for booking-specific adapters.
|
|
5
|
+
Implement these protocols when building a new adapter for the booking engine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import date, datetime, time
|
|
11
|
+
from typing import TYPE_CHECKING, Protocol
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from codex_services.booking._shared.dto import ResourceAvailability
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScheduleProvider(Protocol):
|
|
18
|
+
"""Provides working schedules for resources."""
|
|
19
|
+
|
|
20
|
+
def get_working_hours(self, resource_id: str, target_date: date) -> tuple[time, time] | None:
|
|
21
|
+
"""Return (start, end) of working day or None if day off."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def get_break_interval(self, resource_id: str, target_date: date) -> tuple[datetime, datetime] | None:
|
|
25
|
+
"""Return (start, end) of break or None."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BusySlotsProvider(Protocol):
|
|
30
|
+
"""Provides busy time slots for resources."""
|
|
31
|
+
|
|
32
|
+
def get_busy_intervals(
|
|
33
|
+
self, resource_ids: list[str], target_date: date
|
|
34
|
+
) -> dict[str, list[tuple[datetime, datetime]]]:
|
|
35
|
+
"""Return {resource_id: [(start, end), ...]} of busy times."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AvailabilityProvider(Protocol):
|
|
40
|
+
"""
|
|
41
|
+
Full availability provider — the main adapter contract.
|
|
42
|
+
Implement this protocol for each framework adapter (Django, SQLAlchemy, etc.).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def build_resources_availability(
|
|
46
|
+
self,
|
|
47
|
+
resource_ids: list[str],
|
|
48
|
+
target_date: date,
|
|
49
|
+
cache_ttl: int = 300,
|
|
50
|
+
exclude_appointment_ids: list[int] | None = None,
|
|
51
|
+
) -> dict[str, ResourceAvailability]:
|
|
52
|
+
"""Build availability for a single date. Return {resource_id: ResourceAvailability}."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def build_availability_batch(
|
|
56
|
+
self,
|
|
57
|
+
resource_ids: list[str],
|
|
58
|
+
start_date: date,
|
|
59
|
+
end_date: date,
|
|
60
|
+
) -> dict[date, dict[str, ResourceAvailability]]:
|
|
61
|
+
"""
|
|
62
|
+
Build availability for a date range in a single batch.
|
|
63
|
+
Must avoid N+1 queries — group appointments in memory.
|
|
64
|
+
Return {date: {resource_id: ResourceAvailability}}.
|
|
65
|
+
"""
|
|
66
|
+
...
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_services.booking._shared.validators
|
|
3
|
+
==========================================
|
|
4
|
+
Validators for ensuring booking data correctness.
|
|
5
|
+
|
|
6
|
+
Used by ChainFinder to verify found solutions,
|
|
7
|
+
and can also be used independently in tests or the service layer.
|
|
8
|
+
|
|
9
|
+
Imports:
|
|
10
|
+
from codex_services.booking import BookingValidator
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from codex_services.booking._shared.dto import BookingSolution
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BookingValidator:
|
|
19
|
+
"""
|
|
20
|
+
A set of correctness checks for booking data.
|
|
21
|
+
Unaffected by the ORM — operates exclusively on DTOs.
|
|
22
|
+
|
|
23
|
+
Used by:
|
|
24
|
+
- ChainFinder: Ensuring found chains have no conflicts.
|
|
25
|
+
- Adapter: Final verification before creating Appointment instances in the DB.
|
|
26
|
+
- Tests: Isolated logic verification without Django.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
v = BookingValidator()
|
|
30
|
+
|
|
31
|
+
# Check if a slot is free:
|
|
32
|
+
ok = v.is_slot_free(
|
|
33
|
+
slot_start=datetime(2024,5,10,10,0),
|
|
34
|
+
slot_end=datetime(2024,5,10,11,0),
|
|
35
|
+
busy_intervals=[(datetime(2024,5,10,9,0), datetime(2024,5,10,9,30))],
|
|
36
|
+
)
|
|
37
|
+
# → True (no overlap)
|
|
38
|
+
|
|
39
|
+
# Check entire chain for conflicts:
|
|
40
|
+
ok = v.no_conflicts(solutions)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def is_slot_free(
|
|
44
|
+
self,
|
|
45
|
+
slot_start: datetime,
|
|
46
|
+
slot_end: datetime,
|
|
47
|
+
busy_intervals: list[tuple[datetime, datetime]],
|
|
48
|
+
) -> bool:
|
|
49
|
+
"""
|
|
50
|
+
Verifies that the slot [slot_start, slot_end) does not overlap
|
|
51
|
+
with any of the busy intervals.
|
|
52
|
+
|
|
53
|
+
Uses a "half-open" interval — [start, end). If slot_end == busy_start,
|
|
54
|
+
it is NOT considered a conflict (adjacent slots are allowed).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
slot_start: Start of the slot to check.
|
|
58
|
+
slot_end: End of the slot to check.
|
|
59
|
+
busy_intervals: List of busy intervals [(start, end), ...].
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if the slot is free. False if there is an overlap.
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
# Busy 10:00-11:00. Requesting 10:30-11:30 → conflict:
|
|
66
|
+
is_slot_free(10:30, 11:30, [(10:00, 11:00)]) → False
|
|
67
|
+
|
|
68
|
+
# Requesting 11:00-12:00 → OK (adjacent slots):
|
|
69
|
+
is_slot_free(11:00, 12:00, [(10:00, 11:00)]) → True
|
|
70
|
+
"""
|
|
71
|
+
return all(not (slot_start < busy_end and slot_end > busy_start) for busy_start, busy_end in busy_intervals)
|
|
72
|
+
|
|
73
|
+
def no_conflicts(
|
|
74
|
+
self,
|
|
75
|
+
solutions: list[BookingSolution],
|
|
76
|
+
) -> bool:
|
|
77
|
+
"""
|
|
78
|
+
Verifies that there are no resource conflicts within a set of solutions.
|
|
79
|
+
A resource cannot be occupied by two services simultaneously.
|
|
80
|
+
|
|
81
|
+
Groups solutions by resource_id and checks each group for overlaps.
|
|
82
|
+
Used by ChainFinder after assembling the chain for final verification.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
solutions: List of BookingSolution objects (found slots).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if no conflicts exist. False if at least one resource is double-booked.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
no_conflicts([
|
|
92
|
+
SingleServiceSolution(resource_id="1", start=9:00, gap_end=10:10),
|
|
93
|
+
SingleServiceSolution(resource_id="1", start=10:10, gap_end=11:10),
|
|
94
|
+
])
|
|
95
|
+
# → True (slots are adjacent, no overlap)
|
|
96
|
+
"""
|
|
97
|
+
by_resource: dict[str, list[BookingSolution]] = {}
|
|
98
|
+
for sol in solutions:
|
|
99
|
+
by_resource.setdefault(sol.resource_id, []).append(sol)
|
|
100
|
+
|
|
101
|
+
for _resource_id, resource_solutions in by_resource.items():
|
|
102
|
+
if len(resource_solutions) < 2:
|
|
103
|
+
continue
|
|
104
|
+
sorted_sols = sorted(resource_solutions, key=lambda s: s.start_time)
|
|
105
|
+
for i in range(len(sorted_sols) - 1):
|
|
106
|
+
current = sorted_sols[i]
|
|
107
|
+
next_sol = sorted_sols[i + 1]
|
|
108
|
+
# For solutions with gap_end_time, use it; otherwise use end_time
|
|
109
|
+
block_end = getattr(current, "gap_end_time", current.end_time)
|
|
110
|
+
if next_sol.start_time < block_end:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
def solution_fits_in_windows(
|
|
116
|
+
self,
|
|
117
|
+
solution: BookingSolution,
|
|
118
|
+
free_windows: list[tuple[datetime, datetime]],
|
|
119
|
+
) -> bool:
|
|
120
|
+
"""
|
|
121
|
+
Verifies that a solution's slot fits entirely inside one of the
|
|
122
|
+
resource's free windows.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
solution: Found slot for a single service.
|
|
126
|
+
free_windows: Resource's free windows (from ResourceAvailability).
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
True if the slot fits perfectly inside one of the given free windows.
|
|
130
|
+
"""
|
|
131
|
+
block_end = getattr(solution, "gap_end_time", solution.end_time)
|
|
132
|
+
return any(solution.start_time >= w_start and block_end <= w_end for w_start, w_end in free_windows)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Booking engine — chain service scheduling with backtracking."""
|
|
2
|
+
|
|
3
|
+
from codex_services.booking._shared.exceptions import (
|
|
4
|
+
BookingEngineError,
|
|
5
|
+
ChainBuildError,
|
|
6
|
+
InvalidBookingDateError,
|
|
7
|
+
InvalidServiceDurationError,
|
|
8
|
+
NoAvailabilityError,
|
|
9
|
+
ResourceNotAvailableError,
|
|
10
|
+
SlotAlreadyBookedError,
|
|
11
|
+
)
|
|
12
|
+
from codex_services.booking._shared.interfaces import AvailabilityProvider, BusySlotsProvider, ScheduleProvider
|
|
13
|
+
|
|
14
|
+
from .api import find_nearest_slots, find_slots
|
|
15
|
+
from .chain_finder import ChainFinder
|
|
16
|
+
from .dto import (
|
|
17
|
+
BookingChainSolution,
|
|
18
|
+
BookingEngineRequest,
|
|
19
|
+
EngineResult,
|
|
20
|
+
MasterAvailability,
|
|
21
|
+
ServiceRequest,
|
|
22
|
+
SingleServiceSolution,
|
|
23
|
+
WaitlistEntry,
|
|
24
|
+
)
|
|
25
|
+
from .modes import BookingMode
|
|
26
|
+
from .scorer import BookingScorer, ScoringWeights
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
# High-level facade (dict-based API)
|
|
30
|
+
"find_slots",
|
|
31
|
+
"find_nearest_slots",
|
|
32
|
+
# Engine
|
|
33
|
+
"ChainFinder",
|
|
34
|
+
"BookingScorer",
|
|
35
|
+
"ScoringWeights",
|
|
36
|
+
# Interfaces
|
|
37
|
+
"AvailabilityProvider",
|
|
38
|
+
"BusySlotsProvider",
|
|
39
|
+
"ScheduleProvider",
|
|
40
|
+
# Request / availability DTOs
|
|
41
|
+
"BookingEngineRequest",
|
|
42
|
+
"ServiceRequest",
|
|
43
|
+
"MasterAvailability",
|
|
44
|
+
"BookingMode",
|
|
45
|
+
# Result DTOs
|
|
46
|
+
"EngineResult",
|
|
47
|
+
"BookingChainSolution",
|
|
48
|
+
"SingleServiceSolution",
|
|
49
|
+
"WaitlistEntry",
|
|
50
|
+
# Exceptions
|
|
51
|
+
"BookingEngineError",
|
|
52
|
+
"NoAvailabilityError",
|
|
53
|
+
"InvalidServiceDurationError",
|
|
54
|
+
"InvalidBookingDateError",
|
|
55
|
+
"ResourceNotAvailableError",
|
|
56
|
+
"SlotAlreadyBookedError",
|
|
57
|
+
"ChainBuildError",
|
|
58
|
+
]
|