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,446 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_services.booking.slot_master.dto
|
|
3
|
+
======================================
|
|
4
|
+
Pydantic v2 DTO (Data Transfer Objects) for the slot-master booking engine.
|
|
5
|
+
|
|
6
|
+
All models are immutable (frozen=True via BaseDTO) — the engine does not mutate inputs.
|
|
7
|
+
No Django imports. Only Python stdlib + pydantic + codex_core.
|
|
8
|
+
|
|
9
|
+
Imports:
|
|
10
|
+
from codex_services.booking.slot_master import (
|
|
11
|
+
BookingEngineRequest, ServiceRequest,
|
|
12
|
+
MasterAvailability, EngineResult, BookingChainSolution,
|
|
13
|
+
)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from datetime import date, datetime
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from codex_core.core.base_dto import BaseDTO
|
|
20
|
+
from pydantic import Field
|
|
21
|
+
|
|
22
|
+
from codex_services.booking._shared.dto import (
|
|
23
|
+
BookingRequest,
|
|
24
|
+
BookingSolution,
|
|
25
|
+
ResourceAvailability,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .modes import BookingMode
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Входные DTO (что подаётся в движок)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ServiceRequest(BaseDTO):
|
|
36
|
+
"""
|
|
37
|
+
Request for a single service within a booking chain.
|
|
38
|
+
|
|
39
|
+
The engine treats this as an atomic requirement. It operates with abstract
|
|
40
|
+
resource IDs (possible_resource_ids) rather than specific business entities.
|
|
41
|
+
|
|
42
|
+
Fields:
|
|
43
|
+
service_id (str):
|
|
44
|
+
Unique identifier of the service. String for universality
|
|
45
|
+
(can be "5", "uuid-xxx", "task-1" — doesn't matter).
|
|
46
|
+
|
|
47
|
+
duration_minutes (int):
|
|
48
|
+
Duration of the service in minutes. Must be > 0.
|
|
49
|
+
|
|
50
|
+
min_gap_after_minutes (int):
|
|
51
|
+
Minimum gap (minutes) after this service before the next one
|
|
52
|
+
in the chain. Default is 0 (no gap).
|
|
53
|
+
Example: if there is a cooling down period of 30 mins → min_gap_after_minutes=30.
|
|
54
|
+
|
|
55
|
+
possible_resource_ids (list[str]):
|
|
56
|
+
List of resource IDs capable of performing this service.
|
|
57
|
+
The engine chooses an available resource from this list.
|
|
58
|
+
For RESOURCE_LOCKED mode, this should contain exactly one element.
|
|
59
|
+
|
|
60
|
+
parallel_group (str | None):
|
|
61
|
+
Tag for parallel execution group.
|
|
62
|
+
Services with the same parallel_group can be performed simultaneously
|
|
63
|
+
by different resources (if overlap_allowed=True is set in the request).
|
|
64
|
+
None = service is performed independently (standard sequential behavior).
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
```python
|
|
68
|
+
ServiceRequest(
|
|
69
|
+
service_id="5",
|
|
70
|
+
duration_minutes=60,
|
|
71
|
+
possible_resource_ids=["1", "3", "7"],
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
service_id: str
|
|
77
|
+
duration_minutes: int = Field(gt=0, description="Длительность в минутах")
|
|
78
|
+
min_gap_after_minutes: int = Field(default=0, ge=0, description="Пауза после услуги перед следующей")
|
|
79
|
+
possible_resource_ids: list[str] = Field(min_length=1, description="Хотя бы один ресурс должен быть указан")
|
|
80
|
+
parallel_group: str | None = Field(
|
|
81
|
+
default=None,
|
|
82
|
+
description="Метка группы параллельного выполнения (одинаковый тег = одновременно)",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def total_block_minutes(self) -> int:
|
|
87
|
+
"""Return total time that blocks the resource: duration + gap after."""
|
|
88
|
+
return self.duration_minutes + self.min_gap_after_minutes
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str:
|
|
91
|
+
return f"<ServiceRequest id={self.service_id} dur={self.duration_minutes}>"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class BookingEngineRequest(BookingRequest):
|
|
95
|
+
"""
|
|
96
|
+
Input request describing the entire desired booking chain.
|
|
97
|
+
|
|
98
|
+
Orchestrates multiple ServiceRequests into a single search task.
|
|
99
|
+
Inherits booking_date from BookingRequest.
|
|
100
|
+
|
|
101
|
+
Fields:
|
|
102
|
+
service_requests (list[ServiceRequest]):
|
|
103
|
+
List of services to book. Order is critical for SINGLE_DAY mode —
|
|
104
|
+
the engine will schedule them in the specified sequence.
|
|
105
|
+
Minimum 1 service required.
|
|
106
|
+
|
|
107
|
+
booking_date (date):
|
|
108
|
+
Target date. Inherited from BookingRequest.
|
|
109
|
+
Used for SINGLE_DAY and RESOURCE_LOCKED.
|
|
110
|
+
In MULTI_DAY mode, this represents the date of the first service.
|
|
111
|
+
|
|
112
|
+
mode (BookingMode):
|
|
113
|
+
Engine operating strategy. Default is SINGLE_DAY.
|
|
114
|
+
|
|
115
|
+
overlap_allowed (bool):
|
|
116
|
+
Allow parallel execution of services by different resources.
|
|
117
|
+
False (default) — each subsequent service starts only after the
|
|
118
|
+
previous one (plus its gap) ends.
|
|
119
|
+
True — resources can work independently; services may start
|
|
120
|
+
simultaneously if resources are available.
|
|
121
|
+
|
|
122
|
+
group_size (int):
|
|
123
|
+
DEPRECATED. Use duplication of ServiceRequest with parallel_group.
|
|
124
|
+
|
|
125
|
+
max_chain_duration_minutes (int | None):
|
|
126
|
+
Maximum total duration of the entire booking (from start of first
|
|
127
|
+
to end of last service). None = no limit.
|
|
128
|
+
|
|
129
|
+
days_gap (list[int] | None):
|
|
130
|
+
Day offsets for each service. Used strictly in MULTI_DAY mode.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
```python
|
|
134
|
+
BookingEngineRequest(
|
|
135
|
+
service_requests=[svc_1, svc_2],
|
|
136
|
+
booking_date=date(2024, 5, 10),
|
|
137
|
+
mode=BookingMode.SINGLE_DAY,
|
|
138
|
+
overlap_allowed=True
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
service_requests: list[ServiceRequest] = Field(min_length=1, description="Минимум одна услуга")
|
|
144
|
+
mode: BookingMode = BookingMode.SINGLE_DAY
|
|
145
|
+
overlap_allowed: bool = Field(
|
|
146
|
+
default=False,
|
|
147
|
+
description="Разрешить параллельное выполнение услуг разными ресурсами",
|
|
148
|
+
)
|
|
149
|
+
group_size: int = Field(
|
|
150
|
+
default=1,
|
|
151
|
+
ge=1,
|
|
152
|
+
description="DEPRECATED. Используйте дублирование ServiceRequest с parallel_group.",
|
|
153
|
+
)
|
|
154
|
+
max_chain_duration_minutes: int | None = Field(
|
|
155
|
+
default=None,
|
|
156
|
+
ge=1,
|
|
157
|
+
description="Макс. длительность всей цепочки в минутах (None = без лимита)",
|
|
158
|
+
)
|
|
159
|
+
days_gap: list[int] | None = Field(
|
|
160
|
+
default=None,
|
|
161
|
+
description="Смещение в днях для каждой услуги (только MULTI_DAY)",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def total_duration_minutes(self) -> int:
|
|
166
|
+
"""Calculate total duration of all services without gap pauses."""
|
|
167
|
+
return sum(s.duration_minutes for s in self.service_requests)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def total_block_minutes(self) -> int:
|
|
171
|
+
"""
|
|
172
|
+
Calculate total blocking time including pauses between services.
|
|
173
|
+
Used for quick checks: does the chain fit into a given window.
|
|
174
|
+
"""
|
|
175
|
+
return sum(s.total_block_minutes for s in self.service_requests)
|
|
176
|
+
|
|
177
|
+
def __repr__(self) -> str:
|
|
178
|
+
return f"<BookingEngineRequest date={self.booking_date} services={len(self.service_requests)}>"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Данные доступности (подготавливаются адаптером)
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class MasterAvailability(ResourceAvailability):
|
|
187
|
+
"""
|
|
188
|
+
Available time windows of a resource for the slot-master booking type.
|
|
189
|
+
|
|
190
|
+
Inherits resource_id and free_windows from ResourceAvailability.
|
|
191
|
+
Adds slot-master-specific fields: buffer_between_minutes and work_start.
|
|
192
|
+
|
|
193
|
+
Fields:
|
|
194
|
+
resource_id (str): Resource identifier (inherited).
|
|
195
|
+
free_windows (list[tuple[datetime, datetime]]): List of (start, end) tuples (inherited).
|
|
196
|
+
buffer_between_minutes (int): Minimum buffer required between bookings.
|
|
197
|
+
work_start (datetime | None): Shift start anchor for slot alignment.
|
|
198
|
+
|
|
199
|
+
Example:
|
|
200
|
+
```python
|
|
201
|
+
MasterAvailability(
|
|
202
|
+
resource_id="resource_1",
|
|
203
|
+
free_windows=[(datetime(2024,5,10,9,0), datetime(2024,5,10,12,0))],
|
|
204
|
+
buffer_between_minutes=10,
|
|
205
|
+
)
|
|
206
|
+
```
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
buffer_between_minutes: int = Field(default=0, ge=0)
|
|
210
|
+
work_start: datetime | None = Field(
|
|
211
|
+
default=None,
|
|
212
|
+
description="Якорь для выравнивания сетки слотов (например, начало смены)",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def __repr__(self) -> str:
|
|
216
|
+
return f"<MasterAvailability resource={self.resource_id} windows={len(self.free_windows)}>"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# Выходные DTO (результат работы движка)
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class SingleServiceSolution(BookingSolution):
|
|
225
|
+
"""
|
|
226
|
+
Found slot for a single service in a booking chain.
|
|
227
|
+
|
|
228
|
+
Inherits resource_id, start_time, end_time from BookingSolution.
|
|
229
|
+
Adds service_id and gap_end_time.
|
|
230
|
+
|
|
231
|
+
Fields:
|
|
232
|
+
service_id (str): Reference to the original ServiceRequest.service_id.
|
|
233
|
+
resource_id (str): Identifier of the resource assigned to this service (inherited).
|
|
234
|
+
start_time (datetime): Scheduled start time of the service (inherited).
|
|
235
|
+
end_time (datetime): Scheduled completion time (excluding gap) (inherited).
|
|
236
|
+
gap_end_time (datetime): End of the blocking period (end_time + gap).
|
|
237
|
+
|
|
238
|
+
Example:
|
|
239
|
+
```python
|
|
240
|
+
slot = SingleServiceSolution(
|
|
241
|
+
service_id="5",
|
|
242
|
+
resource_id="1",
|
|
243
|
+
start_time=datetime(2024, 5, 10, 10, 0),
|
|
244
|
+
end_time=datetime(2024, 5, 10, 11, 0),
|
|
245
|
+
gap_end_time=datetime(2024, 5, 10, 11, 15),
|
|
246
|
+
)
|
|
247
|
+
```
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
service_id: str
|
|
251
|
+
gap_end_time: datetime # end_time + min_gap_after_minutes
|
|
252
|
+
|
|
253
|
+
def __repr__(self) -> str:
|
|
254
|
+
# GDPR Safe: Only IDs and Times. No notes, names, or PII.
|
|
255
|
+
return (
|
|
256
|
+
f"<SingleServiceSolution svc={self.service_id} "
|
|
257
|
+
f"res={self.resource_id} "
|
|
258
|
+
f"start={self.start_time.strftime('%H:%M')}>"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class BookingChainSolution(BaseDTO):
|
|
263
|
+
"""
|
|
264
|
+
One complete solution for the entire request (set of slots for all services).
|
|
265
|
+
|
|
266
|
+
Found by the engine. Guarantees no conflicts between services and
|
|
267
|
+
respects all resource availability constraints.
|
|
268
|
+
|
|
269
|
+
Fields:
|
|
270
|
+
items (list[SingleServiceSolution]):
|
|
271
|
+
List of slots in the order of service execution.
|
|
272
|
+
|
|
273
|
+
score (float):
|
|
274
|
+
Quality score of the solution (higher is better).
|
|
275
|
+
Can be influenced by preferred resources, idle time, or resource reuse.
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
```python
|
|
279
|
+
solution = BookingChainSolution(items=[slot1, slot2], score=10.0)
|
|
280
|
+
print(f"Booking span: {solution.span_minutes} minutes")
|
|
281
|
+
```
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
items: list[SingleServiceSolution] = Field(min_length=1)
|
|
285
|
+
score: float = Field(default=0.0, description="Оценка качества решения")
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def starts_at(self) -> datetime:
|
|
289
|
+
"""Return the start time of the first service in the chain."""
|
|
290
|
+
return min(s.start_time for s in self.items)
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def ends_at(self) -> datetime:
|
|
294
|
+
"""Return the end time of the last service (excluding gap)."""
|
|
295
|
+
return max(s.end_time for s in self.items)
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def span_minutes(self) -> int:
|
|
299
|
+
"""Return total time from the start of the first to the end of the last service."""
|
|
300
|
+
return int((self.ends_at - self.starts_at).total_seconds() / 60)
|
|
301
|
+
|
|
302
|
+
def to_display(self) -> dict[str, Any]:
|
|
303
|
+
"""
|
|
304
|
+
Convert the solution into a dictionary for UI/serialization.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Dict: {service_id: {resource_id, start, end}, ...}
|
|
308
|
+
"""
|
|
309
|
+
return {
|
|
310
|
+
item.service_id: {
|
|
311
|
+
"resource_id": item.resource_id,
|
|
312
|
+
"start": item.start_time.strftime("%H:%M"),
|
|
313
|
+
"end": item.end_time.strftime("%H:%M"),
|
|
314
|
+
}
|
|
315
|
+
for item in self.items
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
def __repr__(self) -> str:
|
|
319
|
+
# GDPR Safe: Only structural info.
|
|
320
|
+
return f"<BookingChainSolution score={self.score:.2f} items={self.items}>"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class EngineResult(BaseDTO):
|
|
324
|
+
"""
|
|
325
|
+
Engine work result containing all discovered solutions.
|
|
326
|
+
|
|
327
|
+
Fields:
|
|
328
|
+
mode (BookingMode): The search strategy used.
|
|
329
|
+
solutions (list[BookingChainSolution]): Found valid schedule options.
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
```python
|
|
333
|
+
result = ChainFinder().find(request, availability)
|
|
334
|
+
if result.has_solutions:
|
|
335
|
+
print(f"Best start: {result.best.starts_at}")
|
|
336
|
+
```
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
mode: BookingMode
|
|
340
|
+
solutions: list[BookingChainSolution] = Field(default_factory=list)
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def has_solutions(self) -> bool:
|
|
344
|
+
"""Return True if at least one solution was found."""
|
|
345
|
+
return len(self.solutions) > 0
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def best(self) -> BookingChainSolution | None:
|
|
349
|
+
"""
|
|
350
|
+
Return the primary solution.
|
|
351
|
+
- Without scorer: earliest by start time.
|
|
352
|
+
- After scoring: the option with the highest score.
|
|
353
|
+
"""
|
|
354
|
+
return self.solutions[0] if self.solutions else None
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def best_scored(self) -> BookingChainSolution | None:
|
|
358
|
+
"""
|
|
359
|
+
Return the option with the maximum score among all solutions.
|
|
360
|
+
Differs from 'best' if the solution list hasn't been sorted yet.
|
|
361
|
+
"""
|
|
362
|
+
if not self.solutions:
|
|
363
|
+
return None
|
|
364
|
+
return max(self.solutions, key=lambda s: s.score)
|
|
365
|
+
|
|
366
|
+
def get_unique_start_times(self) -> list[str]:
|
|
367
|
+
"""
|
|
368
|
+
Return unique start times of the first service for UI grid display.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
List[str]: ["09:00", "09:30", ...]
|
|
372
|
+
"""
|
|
373
|
+
times = {s.starts_at.strftime("%H:%M") for s in self.solutions}
|
|
374
|
+
return sorted(times)
|
|
375
|
+
|
|
376
|
+
def __repr__(self) -> str:
|
|
377
|
+
return f"<EngineResult mode={self.mode} solutions_count={len(self.solutions)}>"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
# Waitlist DTO (результат find_nearest / waitlist-уведомлений)
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class WaitlistEntry(BaseDTO):
|
|
386
|
+
"""
|
|
387
|
+
Notification data for a nearest available slot for waitlisted clients.
|
|
388
|
+
|
|
389
|
+
Used when a desired slot was unavailable, but an alternative was found
|
|
390
|
+
(e.g., via find_nearest() or a background worker).
|
|
391
|
+
|
|
392
|
+
Fields:
|
|
393
|
+
available_date (date): Date of the found alternative slot.
|
|
394
|
+
available_time (str): Start time of the first service ("HH:MM").
|
|
395
|
+
solution (BookingChainSolution): Complete schedule details.
|
|
396
|
+
days_from_request (int): Delta from original request date for ranking.
|
|
397
|
+
|
|
398
|
+
Example:
|
|
399
|
+
# Worker detects a cancellation:
|
|
400
|
+
result = finder.find_nearest(request, search_from=original_date)
|
|
401
|
+
if result.has_solutions:
|
|
402
|
+
entry = WaitlistEntry.from_engine_result(result, original_date)
|
|
403
|
+
notify_client(entry)
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
available_date: date
|
|
407
|
+
available_time: str # "HH:MM"
|
|
408
|
+
solution: BookingChainSolution
|
|
409
|
+
days_from_request: int = 0
|
|
410
|
+
|
|
411
|
+
@classmethod
|
|
412
|
+
def from_engine_result(
|
|
413
|
+
cls,
|
|
414
|
+
result: "EngineResult",
|
|
415
|
+
original_date: date,
|
|
416
|
+
) -> "WaitlistEntry | None":
|
|
417
|
+
"""
|
|
418
|
+
Factory method to create a WaitlistEntry from an EngineResult.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
result: The engine's output.
|
|
422
|
+
original_date: Original requested date for delta calculation.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
WaitlistEntry or None if no solutions exist.
|
|
426
|
+
"""
|
|
427
|
+
if not result.has_solutions:
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
solution = result.best
|
|
431
|
+
if solution is None: # pragma: no cover
|
|
432
|
+
return None # pragma: no cover
|
|
433
|
+
|
|
434
|
+
available_date = solution.starts_at.date()
|
|
435
|
+
available_time = solution.starts_at.strftime("%H:%M")
|
|
436
|
+
days_delta = (available_date - original_date).days
|
|
437
|
+
|
|
438
|
+
return cls(
|
|
439
|
+
available_date=available_date,
|
|
440
|
+
available_time=available_time,
|
|
441
|
+
solution=solution,
|
|
442
|
+
days_from_request=max(0, days_delta),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def __repr__(self) -> str:
|
|
446
|
+
return f"<WaitlistEntry date={self.available_date} time={self.available_time}>"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_services.booking.slot_master.modes
|
|
3
|
+
========================================
|
|
4
|
+
Operating modes for the slot-master booking engine.
|
|
5
|
+
|
|
6
|
+
Imports:
|
|
7
|
+
from codex_services.booking.slot_master import BookingMode
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 11): # pragma: no cover
|
|
14
|
+
from enum import StrEnum # pragma: no cover
|
|
15
|
+
else:
|
|
16
|
+
|
|
17
|
+
class StrEnum(str, Enum):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BookingMode(StrEnum):
|
|
22
|
+
"""
|
|
23
|
+
Operating mode for ChainFinder.
|
|
24
|
+
|
|
25
|
+
SINGLE_DAY:
|
|
26
|
+
All services from the request must fit within a single day.
|
|
27
|
+
The common mode — multiple services in one visit.
|
|
28
|
+
|
|
29
|
+
MULTI_DAY:
|
|
30
|
+
Each service can be scheduled for a different day (Stub).
|
|
31
|
+
|
|
32
|
+
RESOURCE_LOCKED:
|
|
33
|
+
Booking for a specific resource (e.g., from their personal page).
|
|
34
|
+
The engine relies entirely on the provided resource constraints.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
```python
|
|
38
|
+
mode = BookingMode.SINGLE_DAY
|
|
39
|
+
```
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
SINGLE_DAY = "single_day"
|
|
43
|
+
MULTI_DAY = "multi_day"
|
|
44
|
+
RESOURCE_LOCKED = "resource_locked"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codex_services.booking.slot_master.scorer
|
|
3
|
+
=========================================
|
|
4
|
+
Evaluation and ranking of booking engine solutions.
|
|
5
|
+
|
|
6
|
+
Pure Python — no Django dependencies.
|
|
7
|
+
Applied AFTER ChainFinder.find() to sort solutions by quality.
|
|
8
|
+
|
|
9
|
+
Does not affect the search algorithm — only the output order.
|
|
10
|
+
BookingChainSolution.score is set here.
|
|
11
|
+
|
|
12
|
+
Quick start:
|
|
13
|
+
from codex_services.booking.slot_master import ChainFinder
|
|
14
|
+
from codex_services.booking.slot_master.scorer import BookingScorer, ScoringWeights
|
|
15
|
+
|
|
16
|
+
result = ChainFinder().find(request, availability)
|
|
17
|
+
|
|
18
|
+
scorer = BookingScorer(
|
|
19
|
+
weights=ScoringWeights(preferred_resource_bonus=15.0),
|
|
20
|
+
preferred_resource_ids=["3", "7"],
|
|
21
|
+
)
|
|
22
|
+
ranked = scorer.score(result)
|
|
23
|
+
best = ranked.best # solution with the highest score
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
from .dto import BookingChainSolution, EngineResult
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ScoringWeights:
|
|
33
|
+
"""
|
|
34
|
+
Weights for solution evaluation criteria. Configurable per project.
|
|
35
|
+
|
|
36
|
+
All weights are additive: total score = sum of applicable bonuses.
|
|
37
|
+
Higher score = more preferred solution.
|
|
38
|
+
|
|
39
|
+
Fields:
|
|
40
|
+
preferred_resource_bonus (float):
|
|
41
|
+
Bonus for each service with a preferred resource.
|
|
42
|
+
Passed via BookingScorer(preferred_resource_ids=[...]).
|
|
43
|
+
Example: client always visits Anya -> preferred_resource_ids=["3"].
|
|
44
|
+
|
|
45
|
+
same_resource_bonus (float):
|
|
46
|
+
Bonus if one resource performs multiple services.
|
|
47
|
+
Reduces the number of times the client must switch resources.
|
|
48
|
+
|
|
49
|
+
min_idle_bonus_per_hour (float):
|
|
50
|
+
Bonus for each hour of minimized idle time between services.
|
|
51
|
+
Encourages compact chains.
|
|
52
|
+
|
|
53
|
+
early_slot_penalty_per_hour (float):
|
|
54
|
+
Penalty for each hour from the start of the workday to the first service.
|
|
55
|
+
Encourages earlier slots (less index = better).
|
|
56
|
+
Negative value is not needed here — subtracted automatically.
|
|
57
|
+
|
|
58
|
+
Example (encourage early times AND preferred resource):
|
|
59
|
+
ScoringWeights(
|
|
60
|
+
preferred_resource_bonus=20.0,
|
|
61
|
+
early_slot_penalty_per_hour=1.0,
|
|
62
|
+
)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
preferred_resource_bonus: float = 10.0
|
|
66
|
+
same_resource_bonus: float = 5.0
|
|
67
|
+
min_idle_bonus_per_hour: float = 2.0
|
|
68
|
+
early_slot_penalty_per_hour: float = 0.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BookingScorer:
|
|
72
|
+
"""
|
|
73
|
+
Evaluates engine solutions and returns an EngineResult with populated scores.
|
|
74
|
+
|
|
75
|
+
Usage:
|
|
76
|
+
scorer = BookingScorer(
|
|
77
|
+
weights=ScoringWeights(preferred_resource_bonus=15.0),
|
|
78
|
+
preferred_resource_ids=["3", "7"],
|
|
79
|
+
)
|
|
80
|
+
ranked = scorer.score(result)
|
|
81
|
+
|
|
82
|
+
# Best solution by score (not just the earliest):
|
|
83
|
+
print(ranked.best.score)
|
|
84
|
+
print(ranked.best.to_display())
|
|
85
|
+
|
|
86
|
+
# All solutions sorted by score (highest -> first):
|
|
87
|
+
for solution in ranked.solutions:
|
|
88
|
+
print(solution.score, solution.starts_at)
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
weights: ScoringWeights | None = None,
|
|
94
|
+
preferred_resource_ids: list[str] | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Args:
|
|
98
|
+
weights: Criteria weights. None = ScoringWeights() with defaults.
|
|
99
|
+
preferred_resource_ids: List of IDs for preferred resources.
|
|
100
|
+
Must be strings (str(resource.pk)).
|
|
101
|
+
On match -> preferred_resource_bonus applied.
|
|
102
|
+
"""
|
|
103
|
+
self.weights = weights or ScoringWeights()
|
|
104
|
+
self.preferred_ids: set[str] = set(preferred_resource_ids or [])
|
|
105
|
+
|
|
106
|
+
def score(self, result: EngineResult) -> EngineResult:
|
|
107
|
+
"""
|
|
108
|
+
Populates a score for each solution and returns a re-sorted EngineResult.
|
|
109
|
+
|
|
110
|
+
Sorting: descending by score (best = first = result.best).
|
|
111
|
+
Solutions with the same score are sorted by starts_at (earlier = better).
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
result: EngineResult from ChainFinder.find().
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
New EngineResult (using frozen=True -> model_copy) with populated scores
|
|
118
|
+
and solutions sorted by score DESC.
|
|
119
|
+
If result is empty, it is returned unchanged.
|
|
120
|
+
"""
|
|
121
|
+
if not result.solutions:
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
scored = [self._score_solution(s) for s in result.solutions]
|
|
125
|
+
# Sorting: score DESC, then starts_at ASC (earlier is better on equal score)
|
|
126
|
+
scored.sort(key=lambda s: (-s.score, s.starts_at))
|
|
127
|
+
|
|
128
|
+
return result.model_copy(update={"solutions": scored})
|
|
129
|
+
|
|
130
|
+
def _score_solution(self, solution: BookingChainSolution) -> BookingChainSolution:
|
|
131
|
+
"""Calculates score for a single solution."""
|
|
132
|
+
score = 0.0
|
|
133
|
+
w = self.weights
|
|
134
|
+
|
|
135
|
+
# --- Bonus for preferred resource ---
|
|
136
|
+
if self.preferred_ids:
|
|
137
|
+
for item in solution.items:
|
|
138
|
+
if item.resource_id in self.preferred_ids:
|
|
139
|
+
score += w.preferred_resource_bonus
|
|
140
|
+
|
|
141
|
+
# --- Bonus for one resource carrying out multiple services ---
|
|
142
|
+
if w.same_resource_bonus > 0 and len(solution.items) > 1:
|
|
143
|
+
resource_counts: dict[str, int] = {}
|
|
144
|
+
for item in solution.items:
|
|
145
|
+
resource_counts[item.resource_id] = resource_counts.get(item.resource_id, 0) + 1
|
|
146
|
+
# Bonus for each pair of services with the same resource
|
|
147
|
+
for count in resource_counts.values():
|
|
148
|
+
if count > 1:
|
|
149
|
+
score += w.same_resource_bonus * (count - 1)
|
|
150
|
+
|
|
151
|
+
# --- Bonus for chain compactness (minimal idle time) ---
|
|
152
|
+
if w.min_idle_bonus_per_hour > 0 and len(solution.items) > 1:
|
|
153
|
+
# Idle time = span - total service duration
|
|
154
|
+
total_service_minutes = sum(item.duration_minutes for item in solution.items)
|
|
155
|
+
idle_minutes = solution.span_minutes - total_service_minutes
|
|
156
|
+
idle_hours = idle_minutes / 60.0
|
|
157
|
+
# Less idle = higher bonus (max for 0 idle)
|
|
158
|
+
max_idle_hours = solution.span_minutes / 60.0
|
|
159
|
+
compactness = 1.0 - (idle_hours / max_idle_hours) if max_idle_hours > 0 else 1.0
|
|
160
|
+
score += w.min_idle_bonus_per_hour * compactness * max_idle_hours
|
|
161
|
+
|
|
162
|
+
# --- Penalty for late start (encourages early slots) ---
|
|
163
|
+
if w.early_slot_penalty_per_hour > 0:
|
|
164
|
+
# Calculate hours from the start of the day (00:00) to starts_at
|
|
165
|
+
starts_hour = solution.starts_at.hour + solution.starts_at.minute / 60.0
|
|
166
|
+
score -= w.early_slot_penalty_per_hour * starts_hour
|
|
167
|
+
|
|
168
|
+
return solution.model_copy(update={"score": round(score, 4)})
|