plansolve 0.25.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.
- plansolve/__init__.py +66 -0
- plansolve/client.py +40 -0
- plansolve/field_service/__init__.py +34 -0
- plansolve/field_service/api_client.py +212 -0
- plansolve/field_service/constraint_weight.py +143 -0
- plansolve/field_service/models.py +427 -0
- plansolve/professional_services/__init__.py +28 -0
- plansolve/professional_services/api_client.py +209 -0
- plansolve/professional_services/availability_time_span.py +31 -0
- plansolve/professional_services/contract.py +39 -0
- plansolve/professional_services/models.py +265 -0
- plansolve/py.typed +4 -0
- plansolve/routes.py +8 -0
- plansolve/shift/__init__.py +45 -0
- plansolve/shift/api_client.py +210 -0
- plansolve/shift/constraint_weight_overrides.py +19 -0
- plansolve/shift/contract.py +62 -0
- plansolve/shift/day_off_request.py +30 -0
- plansolve/shift/fairness.py +30 -0
- plansolve/shift/fairness_bucket.py +38 -0
- plansolve/shift/models.py +140 -0
- plansolve/shift/options.py +34 -0
- plansolve/shift/period_rule.py +42 -0
- plansolve/shift/planning_period.py +23 -0
- plansolve/shift/shift_assignment.py +59 -0
- plansolve/shift/shift_employee.py +69 -0
- plansolve/shift/shift_off_request.py +30 -0
- plansolve/shift/weights.py +94 -0
- plansolve/solver_status.py +23 -0
- plansolve-0.25.0.dist-info/METADATA +788 -0
- plansolve-0.25.0.dist-info/RECORD +34 -0
- plansolve-0.25.0.dist-info/WHEEL +5 -0
- plansolve-0.25.0.dist-info/licenses/LICENSE +24 -0
- plansolve-0.25.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Data models for field service optimization."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional, Tuple, Dict, Any, Literal
|
|
5
|
+
|
|
6
|
+
from .constraint_weight import ConstraintWeight
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Shift:
|
|
11
|
+
"""Represents a work shift for a vehicle."""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
min_start_time: str # ISO 8601 datetime string
|
|
15
|
+
max_end_time: str # ISO 8601 datetime string
|
|
16
|
+
|
|
17
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
18
|
+
"""Convert to dictionary for API serialization."""
|
|
19
|
+
return {
|
|
20
|
+
"id": self.id,
|
|
21
|
+
"minStartTime": self.min_start_time,
|
|
22
|
+
"maxEndTime": self.max_end_time,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Shift":
|
|
27
|
+
"""Create from dictionary."""
|
|
28
|
+
return cls(
|
|
29
|
+
id=data["id"],
|
|
30
|
+
min_start_time=data["minStartTime"],
|
|
31
|
+
max_end_time=data["maxEndTime"],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TimeWindow:
|
|
37
|
+
"""Represents a time window for a visit."""
|
|
38
|
+
|
|
39
|
+
min_start_time: str # ISO 8601 datetime string
|
|
40
|
+
max_end_time: str # ISO 8601 datetime string
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
43
|
+
"""Convert to dictionary for API serialization."""
|
|
44
|
+
return {
|
|
45
|
+
"minStartTime": self.min_start_time,
|
|
46
|
+
"maxEndTime": self.max_end_time,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_dict(cls, data: Dict[str, Any]) -> "TimeWindow":
|
|
51
|
+
"""Create from dictionary."""
|
|
52
|
+
return cls(
|
|
53
|
+
min_start_time=data["minStartTime"],
|
|
54
|
+
max_end_time=data["maxEndTime"],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Vehicle:
|
|
60
|
+
"""Represents a vehicle (field technician) for optimization."""
|
|
61
|
+
|
|
62
|
+
id: str
|
|
63
|
+
location: Tuple[float, float] # [latitude, longitude]
|
|
64
|
+
shifts: List[Shift]
|
|
65
|
+
skills: List[str] = field(default_factory=list)
|
|
66
|
+
name: Optional[str] = None
|
|
67
|
+
departure_time: Optional[str] = None
|
|
68
|
+
visits: Optional[List[str]] = None
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
71
|
+
"""Convert to dictionary for API serialization."""
|
|
72
|
+
result = {
|
|
73
|
+
"id": self.id,
|
|
74
|
+
"location": list(self.location),
|
|
75
|
+
"shifts": [shift.to_dict() for shift in self.shifts],
|
|
76
|
+
"skills": self.skills,
|
|
77
|
+
}
|
|
78
|
+
if self.name is not None:
|
|
79
|
+
result["name"] = self.name
|
|
80
|
+
if self.departure_time is not None:
|
|
81
|
+
result["departureTime"] = self.departure_time
|
|
82
|
+
if self.visits is not None:
|
|
83
|
+
result["visits"] = self.visits
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Vehicle":
|
|
88
|
+
"""Create from dictionary."""
|
|
89
|
+
return cls(
|
|
90
|
+
id=data["id"],
|
|
91
|
+
location=tuple(data["location"]),
|
|
92
|
+
shifts=[Shift.from_dict(s) for s in data["shifts"]],
|
|
93
|
+
skills=data.get("skills", []),
|
|
94
|
+
name=data.get("name"),
|
|
95
|
+
departure_time=data.get("departureTime"),
|
|
96
|
+
visits=data.get("visits"),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class Visit:
|
|
102
|
+
"""Represents a visit (customer appointment) for optimization."""
|
|
103
|
+
|
|
104
|
+
id: str
|
|
105
|
+
name: str
|
|
106
|
+
location: Tuple[float, float] # [latitude, longitude]
|
|
107
|
+
time_windows: List[TimeWindow]
|
|
108
|
+
service_duration: str # ISO 8601 duration string
|
|
109
|
+
priority: Literal["HIGH", "MEDIUM", "LOW"]
|
|
110
|
+
required_skills: List[str] = field(default_factory=list)
|
|
111
|
+
pinned: bool = False
|
|
112
|
+
vehicle: Optional[str] = None
|
|
113
|
+
arrival_time: Optional[str] = None
|
|
114
|
+
departure_time: Optional[str] = None
|
|
115
|
+
start_service_time: Optional[str] = None
|
|
116
|
+
driving_time_seconds_from_previous_standstill: Optional[int] = None
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
119
|
+
"""Convert to dictionary for API serialization."""
|
|
120
|
+
result = {
|
|
121
|
+
"id": self.id,
|
|
122
|
+
"name": self.name,
|
|
123
|
+
"location": list(self.location),
|
|
124
|
+
"timeWindows": [tw.to_dict() for tw in self.time_windows],
|
|
125
|
+
"serviceDuration": self.service_duration,
|
|
126
|
+
"priority": self.priority,
|
|
127
|
+
"requiredSkills": self.required_skills,
|
|
128
|
+
"pinned": self.pinned,
|
|
129
|
+
}
|
|
130
|
+
if self.vehicle is not None:
|
|
131
|
+
result["vehicle"] = self.vehicle
|
|
132
|
+
if self.arrival_time is not None:
|
|
133
|
+
result["arrivalTime"] = self.arrival_time
|
|
134
|
+
if self.departure_time is not None:
|
|
135
|
+
result["departureTime"] = self.departure_time
|
|
136
|
+
if self.start_service_time is not None:
|
|
137
|
+
result["startServiceTime"] = self.start_service_time
|
|
138
|
+
if self.driving_time_seconds_from_previous_standstill is not None:
|
|
139
|
+
result["drivingTimeSecondsFromPreviousStandstill"] = self.driving_time_seconds_from_previous_standstill
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Visit":
|
|
144
|
+
"""Create from dictionary."""
|
|
145
|
+
return cls(
|
|
146
|
+
id=data["id"],
|
|
147
|
+
name=data["name"],
|
|
148
|
+
location=tuple(data["location"]),
|
|
149
|
+
time_windows=[TimeWindow.from_dict(tw) for tw in data["timeWindows"]],
|
|
150
|
+
service_duration=data["serviceDuration"],
|
|
151
|
+
priority=data["priority"],
|
|
152
|
+
required_skills=data.get("requiredSkills", []),
|
|
153
|
+
pinned=data.get("pinned", False),
|
|
154
|
+
vehicle=data.get("vehicle"),
|
|
155
|
+
arrival_time=data.get("arrivalTime"),
|
|
156
|
+
departure_time=data.get("departureTime"),
|
|
157
|
+
start_service_time=data.get("startServiceTime"),
|
|
158
|
+
driving_time_seconds_from_previous_standstill=data.get("drivingTimeSecondsFromPreviousStandstill"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class SolverOptions:
|
|
164
|
+
"""Solver options for field service optimization."""
|
|
165
|
+
|
|
166
|
+
spent_limit: Optional[str] = None # ISO-8601 duration format, e.g., "PT5M"
|
|
167
|
+
unimproved_spent_limit: Optional[str] = None # ISO-8601 duration format
|
|
168
|
+
|
|
169
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
170
|
+
"""Convert to dictionary for API serialization."""
|
|
171
|
+
result = {}
|
|
172
|
+
if self.spent_limit is not None:
|
|
173
|
+
result["spentLimit"] = self.spent_limit
|
|
174
|
+
if self.unimproved_spent_limit is not None:
|
|
175
|
+
result["unimprovedSpentLimit"] = self.unimproved_spent_limit
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_dict(cls, data: Dict[str, Any]) -> "SolverOptions":
|
|
180
|
+
"""Create from dictionary."""
|
|
181
|
+
return cls(
|
|
182
|
+
spent_limit=data.get("spentLimit"),
|
|
183
|
+
unimproved_spent_limit=data.get("unimprovedSpentLimit"),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class Weights:
|
|
189
|
+
"""Configuration weights for field service optimization constraints."""
|
|
190
|
+
|
|
191
|
+
pinned_visit_vehicle_assignment: Optional[ConstraintWeight] = None
|
|
192
|
+
pinned_visit_service_time: Optional[ConstraintWeight] = None
|
|
193
|
+
no_missing_skills: Optional[ConstraintWeight] = None
|
|
194
|
+
service_time_missing: Optional[ConstraintWeight] = None
|
|
195
|
+
vehicle_unassigned: Optional[ConstraintWeight] = None
|
|
196
|
+
prefer_high_priority: Optional[ConstraintWeight] = None
|
|
197
|
+
minimize_travel_time: Optional[ConstraintWeight] = None
|
|
198
|
+
prefer_using_idle_vehicles: Optional[ConstraintWeight] = None
|
|
199
|
+
minimize_early_arrival_wait: Optional[ConstraintWeight] = None
|
|
200
|
+
prefer_initial_vehicle_assignment: Optional[ConstraintWeight] = None
|
|
201
|
+
prefer_earlier_visit_dates: Optional[ConstraintWeight] = None
|
|
202
|
+
|
|
203
|
+
def to_dict(self) -> Dict[str, str]:
|
|
204
|
+
"""Convert to dictionary for API serialization."""
|
|
205
|
+
result = {}
|
|
206
|
+
if self.pinned_visit_vehicle_assignment is not None:
|
|
207
|
+
result["pinnedVisitVehicleAssignment"] = str(
|
|
208
|
+
self.pinned_visit_vehicle_assignment
|
|
209
|
+
)
|
|
210
|
+
if self.pinned_visit_service_time is not None:
|
|
211
|
+
result["pinnedVisitServiceTime"] = str(self.pinned_visit_service_time)
|
|
212
|
+
if self.no_missing_skills is not None:
|
|
213
|
+
result["noMissingSkills"] = str(self.no_missing_skills)
|
|
214
|
+
if self.service_time_missing is not None:
|
|
215
|
+
result["serviceTimeMissing"] = str(self.service_time_missing)
|
|
216
|
+
if self.vehicle_unassigned is not None:
|
|
217
|
+
result["vehicleUnassigned"] = str(self.vehicle_unassigned)
|
|
218
|
+
if self.prefer_high_priority is not None:
|
|
219
|
+
result["preferHighPriority"] = str(self.prefer_high_priority)
|
|
220
|
+
if self.minimize_travel_time is not None:
|
|
221
|
+
result["minimizeTravelTime"] = str(self.minimize_travel_time)
|
|
222
|
+
if self.prefer_using_idle_vehicles is not None:
|
|
223
|
+
result["preferUsingIdleVehicles"] = str(self.prefer_using_idle_vehicles)
|
|
224
|
+
if self.minimize_early_arrival_wait is not None:
|
|
225
|
+
result["minimizeEarlyArrivalWait"] = str(self.minimize_early_arrival_wait)
|
|
226
|
+
if self.prefer_initial_vehicle_assignment is not None:
|
|
227
|
+
result["preferInitialVehicleAssignment"] = str(
|
|
228
|
+
self.prefer_initial_vehicle_assignment
|
|
229
|
+
)
|
|
230
|
+
if self.prefer_earlier_visit_dates is not None:
|
|
231
|
+
result["PreferEarlierVisitDates"] = str(self.prefer_earlier_visit_dates)
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Weights":
|
|
236
|
+
"""Create from dictionary."""
|
|
237
|
+
from .constraint_weight import ConstraintWeight
|
|
238
|
+
|
|
239
|
+
def parse_weight(value: Any) -> Optional[ConstraintWeight]:
|
|
240
|
+
if value is None:
|
|
241
|
+
return None
|
|
242
|
+
if isinstance(value, ConstraintWeight):
|
|
243
|
+
return value
|
|
244
|
+
if isinstance(value, str):
|
|
245
|
+
return ConstraintWeight.try_parse(value)
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
return cls(
|
|
249
|
+
pinned_visit_vehicle_assignment=parse_weight(
|
|
250
|
+
data.get("pinnedVisitVehicleAssignment")
|
|
251
|
+
),
|
|
252
|
+
pinned_visit_service_time=parse_weight(data.get("pinnedVisitServiceTime")),
|
|
253
|
+
no_missing_skills=parse_weight(data.get("noMissingSkills")),
|
|
254
|
+
service_time_missing=parse_weight(data.get("serviceTimeMissing")),
|
|
255
|
+
vehicle_unassigned=parse_weight(data.get("vehicleUnassigned")),
|
|
256
|
+
prefer_high_priority=parse_weight(data.get("preferHighPriority")),
|
|
257
|
+
minimize_travel_time=parse_weight(data.get("minimizeTravelTime")),
|
|
258
|
+
prefer_using_idle_vehicles=parse_weight(
|
|
259
|
+
data.get("preferUsingIdleVehicles")
|
|
260
|
+
),
|
|
261
|
+
minimize_early_arrival_wait=parse_weight(
|
|
262
|
+
data.get("minimizeEarlyArrivalWait")
|
|
263
|
+
),
|
|
264
|
+
prefer_initial_vehicle_assignment=parse_weight(
|
|
265
|
+
data.get("preferInitialVehicleAssignment")
|
|
266
|
+
),
|
|
267
|
+
prefer_earlier_visit_dates=parse_weight(
|
|
268
|
+
data.get("PreferEarlierVisitDates")
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
274
|
+
class FieldServiceRequest:
|
|
275
|
+
"""Request for field service optimization."""
|
|
276
|
+
|
|
277
|
+
vehicles: List[Vehicle]
|
|
278
|
+
visits: List[Visit]
|
|
279
|
+
weights: Optional[Weights] = None
|
|
280
|
+
options: Optional[SolverOptions] = None
|
|
281
|
+
|
|
282
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
283
|
+
"""Convert to dictionary for API serialization."""
|
|
284
|
+
result = {
|
|
285
|
+
"vehicles": [v.to_dict() for v in self.vehicles],
|
|
286
|
+
"visits": [v.to_dict() for v in self.visits],
|
|
287
|
+
}
|
|
288
|
+
if self.weights is not None:
|
|
289
|
+
weights_dict = self.weights.to_dict()
|
|
290
|
+
if weights_dict:
|
|
291
|
+
result["weights"] = weights_dict
|
|
292
|
+
if self.options is not None:
|
|
293
|
+
options_dict = self.options.to_dict()
|
|
294
|
+
if options_dict:
|
|
295
|
+
result["options"] = options_dict
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
def from_dict(cls, data: Dict[str, Any]) -> "FieldServiceRequest":
|
|
300
|
+
"""Create from dictionary."""
|
|
301
|
+
return cls(
|
|
302
|
+
vehicles=[Vehicle.from_dict(v) for v in data["vehicles"]],
|
|
303
|
+
visits=[Visit.from_dict(v) for v in data["visits"]],
|
|
304
|
+
weights=Weights.from_dict(data["weights"]) if data.get("weights") else None,
|
|
305
|
+
options=(
|
|
306
|
+
SolverOptions.from_dict(data["options"])
|
|
307
|
+
if data.get("options")
|
|
308
|
+
else None
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass
|
|
314
|
+
class ScheduledVehicle:
|
|
315
|
+
"""Scheduled vehicle with assigned visits."""
|
|
316
|
+
|
|
317
|
+
id: str
|
|
318
|
+
location: Tuple[float, float]
|
|
319
|
+
shifts: List[Shift]
|
|
320
|
+
skills: List[str]
|
|
321
|
+
visits: List[str] # List of visit IDs
|
|
322
|
+
daily_return_times: Optional[Dict[str, Any]] = None
|
|
323
|
+
total_driving_time_seconds: Optional[int] = None
|
|
324
|
+
arrival_time: Optional[str] = None
|
|
325
|
+
departure_time: Optional[str] = None
|
|
326
|
+
|
|
327
|
+
@classmethod
|
|
328
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ScheduledVehicle":
|
|
329
|
+
"""Create from dictionary."""
|
|
330
|
+
return cls(
|
|
331
|
+
id=data["id"],
|
|
332
|
+
location=tuple(data["location"]),
|
|
333
|
+
shifts=[Shift.from_dict(s) for s in data["shifts"]],
|
|
334
|
+
skills=data.get("skills", []),
|
|
335
|
+
visits=data.get("visits", []),
|
|
336
|
+
daily_return_times=data.get("dailyReturnTimes"),
|
|
337
|
+
total_driving_time_seconds=data.get("totalDrivingTimeSeconds"),
|
|
338
|
+
arrival_time=data.get("arrivalTime"),
|
|
339
|
+
departure_time=data.get("departureTime"),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass
|
|
344
|
+
class ScheduledVisit:
|
|
345
|
+
"""Scheduled visit with timing information."""
|
|
346
|
+
|
|
347
|
+
id: str
|
|
348
|
+
name: str
|
|
349
|
+
location: Tuple[float, float]
|
|
350
|
+
time_windows: List[TimeWindow]
|
|
351
|
+
service_duration: str
|
|
352
|
+
priority: Literal["HIGH", "MEDIUM", "LOW"]
|
|
353
|
+
required_skills: List[str]
|
|
354
|
+
vehicle: str
|
|
355
|
+
previous_visit: Optional[str]
|
|
356
|
+
arrival_time: str
|
|
357
|
+
departure_time: str
|
|
358
|
+
start_service_time: str
|
|
359
|
+
driving_time_seconds_from_previous_standstill: int
|
|
360
|
+
|
|
361
|
+
@classmethod
|
|
362
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ScheduledVisit":
|
|
363
|
+
"""Create from dictionary."""
|
|
364
|
+
return cls(
|
|
365
|
+
id=data["id"],
|
|
366
|
+
name=data["name"],
|
|
367
|
+
location=tuple(data["location"]),
|
|
368
|
+
time_windows=[TimeWindow.from_dict(tw) for tw in data["timeWindows"]],
|
|
369
|
+
service_duration=data["serviceDuration"],
|
|
370
|
+
priority=data["priority"],
|
|
371
|
+
required_skills=data.get("requiredSkills", []),
|
|
372
|
+
vehicle=data["vehicle"],
|
|
373
|
+
previous_visit=data.get("previousVisit"),
|
|
374
|
+
arrival_time=data["arrivalTime"],
|
|
375
|
+
departure_time=data["departureTime"],
|
|
376
|
+
start_service_time=data["startServiceTime"],
|
|
377
|
+
driving_time_seconds_from_previous_standstill=data[
|
|
378
|
+
"drivingTimeSecondsFromPreviousStandstill"
|
|
379
|
+
],
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass
|
|
384
|
+
class FieldServiceResponse:
|
|
385
|
+
"""Response from starting a field service optimization."""
|
|
386
|
+
|
|
387
|
+
job_id: str
|
|
388
|
+
solver_job_id: str
|
|
389
|
+
result: str
|
|
390
|
+
error: Optional[str] = None
|
|
391
|
+
|
|
392
|
+
@classmethod
|
|
393
|
+
def from_dict(cls, data: Dict[str, Any]) -> "FieldServiceResponse":
|
|
394
|
+
"""Create from dictionary."""
|
|
395
|
+
return cls(
|
|
396
|
+
job_id=data["jobId"],
|
|
397
|
+
solver_job_id=data["solverJobId"],
|
|
398
|
+
result=data["result"],
|
|
399
|
+
error=data.get("error"),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@dataclass
|
|
404
|
+
class FieldServiceResultResponse:
|
|
405
|
+
"""Result response from a completed field service optimization."""
|
|
406
|
+
|
|
407
|
+
vehicles: List[ScheduledVehicle]
|
|
408
|
+
visits: List[ScheduledVisit]
|
|
409
|
+
score: Optional[str] = None
|
|
410
|
+
total_driving_time_seconds: int = 0
|
|
411
|
+
weights: Optional[Dict[str, Any]] = None
|
|
412
|
+
|
|
413
|
+
@classmethod
|
|
414
|
+
def from_dict(cls, data: Dict[str, Any]) -> "FieldServiceResultResponse":
|
|
415
|
+
"""Create from dictionary."""
|
|
416
|
+
return cls(
|
|
417
|
+
vehicles=[
|
|
418
|
+
ScheduledVehicle.from_dict(v) for v in data.get("vehicles", [])
|
|
419
|
+
],
|
|
420
|
+
visits=[ScheduledVisit.from_dict(v) for v in data.get("visits", [])],
|
|
421
|
+
score=data.get("score"),
|
|
422
|
+
total_driving_time_seconds=data.get("totalDrivingTimeSeconds", 0),
|
|
423
|
+
weights=data.get("weights"),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Professional services optimization module."""
|
|
2
|
+
|
|
3
|
+
from .api_client import ProfessionalServicesApiClient
|
|
4
|
+
from .availability_time_span import AvailabilityTimeSpan
|
|
5
|
+
from .contract import Contract
|
|
6
|
+
from .models import (
|
|
7
|
+
Employee,
|
|
8
|
+
Task,
|
|
9
|
+
ProfessionalServicesRequest,
|
|
10
|
+
ScheduledEmployee,
|
|
11
|
+
ScheduledTask,
|
|
12
|
+
ProfessionalServicesResponse,
|
|
13
|
+
ProfessionalServicesResultResponse,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ProfessionalServicesApiClient",
|
|
18
|
+
"AvailabilityTimeSpan",
|
|
19
|
+
"Contract",
|
|
20
|
+
"Employee",
|
|
21
|
+
"Task",
|
|
22
|
+
"ProfessionalServicesRequest",
|
|
23
|
+
"ScheduledEmployee",
|
|
24
|
+
"ScheduledTask",
|
|
25
|
+
"ProfessionalServicesResponse",
|
|
26
|
+
"ProfessionalServicesResultResponse",
|
|
27
|
+
]
|
|
28
|
+
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Professional services API client."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, Optional, Union
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from ..routes import Routes
|
|
9
|
+
from ..solver_status import SOLVER_STATUS, SolverStatusResponse
|
|
10
|
+
from .models import (
|
|
11
|
+
ProfessionalServicesRequest,
|
|
12
|
+
ProfessionalServicesResponse,
|
|
13
|
+
ProfessionalServicesResultResponse,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProfessionalServicesApiClient:
|
|
18
|
+
"""Client for professional services optimization API."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, api_key: Optional[str] = None):
|
|
21
|
+
"""
|
|
22
|
+
Initialize the professional services API client.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
api_key: Optional API key for authenticated requests
|
|
26
|
+
"""
|
|
27
|
+
self.base_url = f"https://plansolve.app{Routes.PROFESSIONAL_SERVICES_SOLVE}"
|
|
28
|
+
self.api_key = api_key
|
|
29
|
+
|
|
30
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
31
|
+
"""Get request headers with optional API key."""
|
|
32
|
+
headers = {"Content-Type": "application/json"}
|
|
33
|
+
if self.api_key:
|
|
34
|
+
headers["X-API-KEY"] = self.api_key
|
|
35
|
+
return headers
|
|
36
|
+
|
|
37
|
+
def start(
|
|
38
|
+
self, request: Union[ProfessionalServicesRequest, Dict[str, Any]]
|
|
39
|
+
) -> ProfessionalServicesResponse:
|
|
40
|
+
"""
|
|
41
|
+
Start a new professional services optimization.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
request: Professional services request (ProfessionalServicesRequest dataclass or dict)
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
ProfessionalServicesResponse with jobId, solverJobId, result, and error
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
requests.RequestException: If the API request fails
|
|
51
|
+
"""
|
|
52
|
+
# Convert dataclass to dict if needed
|
|
53
|
+
if isinstance(request, ProfessionalServicesRequest):
|
|
54
|
+
api_request = request.to_dict()
|
|
55
|
+
else:
|
|
56
|
+
api_request = request
|
|
57
|
+
|
|
58
|
+
response = requests.post(
|
|
59
|
+
self.base_url, json=api_request, headers=self._get_headers()
|
|
60
|
+
)
|
|
61
|
+
response.raise_for_status()
|
|
62
|
+
return ProfessionalServicesResponse.from_dict(response.json())
|
|
63
|
+
|
|
64
|
+
def get_result(self, job_id: str) -> ProfessionalServicesResultResponse:
|
|
65
|
+
"""
|
|
66
|
+
Get the completed optimization result.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
job_id: The job ID to get results for
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
ProfessionalServicesResultResponse with employees and tasks
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
requests.RequestException: If the API request fails
|
|
76
|
+
"""
|
|
77
|
+
url = f"{self.base_url}/{job_id}"
|
|
78
|
+
headers = {}
|
|
79
|
+
if self.api_key:
|
|
80
|
+
headers["X-API-KEY"] = self.api_key
|
|
81
|
+
response = requests.get(url, headers=headers)
|
|
82
|
+
response.raise_for_status()
|
|
83
|
+
return ProfessionalServicesResultResponse.from_dict(response.json())
|
|
84
|
+
|
|
85
|
+
def get_status(self, job_id: str) -> SolverStatusResponse:
|
|
86
|
+
"""
|
|
87
|
+
Get the status of a running optimization job.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
job_id: The job ID to check status for
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Status response dictionary
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
requests.RequestException: If the API request fails
|
|
97
|
+
"""
|
|
98
|
+
url = f"{self.base_url}/{job_id}/status"
|
|
99
|
+
headers = {}
|
|
100
|
+
if self.api_key:
|
|
101
|
+
headers["X-API-KEY"] = self.api_key
|
|
102
|
+
response = requests.get(url, headers=headers)
|
|
103
|
+
response.raise_for_status()
|
|
104
|
+
return response.json()
|
|
105
|
+
|
|
106
|
+
def analyze(self, job_id: str) -> Dict[str, Any]:
|
|
107
|
+
"""
|
|
108
|
+
Get analysis for a completed optimization job.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
job_id: The job ID to get analysis for
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Analysis response dictionary
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
requests.RequestException: If the API request fails
|
|
118
|
+
"""
|
|
119
|
+
url = f"{self.base_url}/{job_id}/analyze"
|
|
120
|
+
headers = {}
|
|
121
|
+
if self.api_key:
|
|
122
|
+
headers["X-API-KEY"] = self.api_key
|
|
123
|
+
response = requests.get(url, headers=headers)
|
|
124
|
+
response.raise_for_status()
|
|
125
|
+
return response.json()
|
|
126
|
+
|
|
127
|
+
def start_and_wait_for_completion(
|
|
128
|
+
self,
|
|
129
|
+
request: Union[ProfessionalServicesRequest, Dict[str, Any]],
|
|
130
|
+
poll_interval_ms: int = 5000,
|
|
131
|
+
max_attempts: int = 1000,
|
|
132
|
+
) -> ProfessionalServicesResultResponse:
|
|
133
|
+
"""
|
|
134
|
+
Start a job and wait for completion, polling for status.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
request: Professional services request (ProfessionalServicesRequest dataclass or dict)
|
|
138
|
+
poll_interval_ms: Milliseconds to wait between status checks
|
|
139
|
+
max_attempts: Maximum number of polling attempts
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
ProfessionalServicesResultResponse with optimization results
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If jobId is not returned or solver times out
|
|
146
|
+
requests.RequestException: If API requests fail
|
|
147
|
+
"""
|
|
148
|
+
start_response = self.start(request)
|
|
149
|
+
return self.wait_for_completion(start_response.job_id, poll_interval_ms, max_attempts)
|
|
150
|
+
|
|
151
|
+
def wait_for_completion(
|
|
152
|
+
self,
|
|
153
|
+
job_id: str,
|
|
154
|
+
poll_interval_ms: int = 5000,
|
|
155
|
+
max_attempts: int = 1000,
|
|
156
|
+
) -> ProfessionalServicesResultResponse:
|
|
157
|
+
"""
|
|
158
|
+
Wait for a job to complete, polling for status.
|
|
159
|
+
Matches the logic used in the .NET SDK implementation exactly.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
job_id: The job ID to wait for
|
|
163
|
+
poll_interval_ms: Milliseconds to wait between status checks
|
|
164
|
+
max_attempts: Maximum number of polling attempts
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
ProfessionalServicesResultResponse with optimization results
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ValueError: If jobId is not provided or solver times out
|
|
171
|
+
requests.RequestException: If API requests fail
|
|
172
|
+
"""
|
|
173
|
+
if not job_id:
|
|
174
|
+
raise ValueError("JobId was not returned from wait_for_completion.")
|
|
175
|
+
|
|
176
|
+
attempts = 0
|
|
177
|
+
status: SolverStatusResponse
|
|
178
|
+
|
|
179
|
+
while True:
|
|
180
|
+
time.sleep(poll_interval_ms / 1000.0) # Convert ms to seconds
|
|
181
|
+
status = self.get_status(job_id)
|
|
182
|
+
attempts += 1
|
|
183
|
+
|
|
184
|
+
if not self._is_still_solving(status) or attempts >= max_attempts:
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
if self._is_still_solving(status):
|
|
188
|
+
raise ValueError("Solver did not finish in the allotted time.")
|
|
189
|
+
|
|
190
|
+
return self.get_result(job_id)
|
|
191
|
+
|
|
192
|
+
def _is_still_solving(self, status: SolverStatusResponse) -> bool:
|
|
193
|
+
"""
|
|
194
|
+
Determine if the solver is still running based on the status response.
|
|
195
|
+
Matches the logic used in the .NET SDK implementation exactly.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
status: Status response dictionary
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
True if still solving, False otherwise
|
|
202
|
+
"""
|
|
203
|
+
return (
|
|
204
|
+
status.get("solving", False)
|
|
205
|
+
or status.get("solverStatus") == SOLVER_STATUS["SOLVING_SCHEDULED"]
|
|
206
|
+
or status.get("solverStatus") != SOLVER_STATUS["NOT_SOLVING"]
|
|
207
|
+
or not status.get("score")
|
|
208
|
+
)
|
|
209
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class AvailabilityTimeSpan:
|
|
7
|
+
id: Optional[str] = None
|
|
8
|
+
start: Optional[str] = None
|
|
9
|
+
end: Optional[str] = None
|
|
10
|
+
type: Optional[str] = None
|
|
11
|
+
|
|
12
|
+
def to_dict(self) -> dict:
|
|
13
|
+
result = {}
|
|
14
|
+
if self.id is not None:
|
|
15
|
+
result["id"] = self.id
|
|
16
|
+
if self.start is not None:
|
|
17
|
+
result["start"] = self.start
|
|
18
|
+
if self.end is not None:
|
|
19
|
+
result["end"] = self.end
|
|
20
|
+
if self.type is not None:
|
|
21
|
+
result["type"] = self.type
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_dict(cls, data: dict) -> "AvailabilityTimeSpan":
|
|
26
|
+
return cls(
|
|
27
|
+
id=data.get("id"),
|
|
28
|
+
start=data.get("start"),
|
|
29
|
+
end=data.get("end"),
|
|
30
|
+
type=data.get("type"),
|
|
31
|
+
)
|