plansolve 0.25.2__tar.gz → 0.25.4__tar.gz
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-0.25.2/plansolve.egg-info → plansolve-0.25.4}/PKG-INFO +1 -1
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/__init__.py +1 -5
- plansolve-0.25.4/plansolve/errors.py +91 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/field_service/__init__.py +0 -5
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/field_service/api_client.py +5 -4
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/field_service/models.py +10 -94
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/professional_services/api_client.py +5 -4
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/api_client.py +5 -4
- {plansolve-0.25.2 → plansolve-0.25.4/plansolve.egg-info}/PKG-INFO +1 -1
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve.egg-info/SOURCES.txt +4 -2
- {plansolve-0.25.2 → plansolve-0.25.4}/pyproject.toml +1 -1
- plansolve-0.25.4/tests/test_error_messages.py +81 -0
- plansolve-0.25.4/tests/test_field_service_weights.py +99 -0
- plansolve-0.25.2/plansolve/field_service/constraint_weight.py +0 -143
- {plansolve-0.25.2 → plansolve-0.25.4}/LICENSE +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/MANIFEST.in +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/README.md +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/client.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/professional_services/__init__.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/professional_services/availability_time_span.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/professional_services/contract.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/professional_services/models.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/py.typed +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/routes.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/__init__.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/constraint_weight_overrides.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/contract.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/day_off_request.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/fairness.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/fairness_bucket.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/models.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/options.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/period_rule.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/planning_period.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/shift_assignment.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/shift_employee.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/shift_off_request.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/shift/weights.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve/solver_status.py +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve.egg-info/dependency_links.txt +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve.egg-info/requires.txt +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/plansolve.egg-info/top_level.txt +0 -0
- {plansolve-0.25.2 → plansolve-0.25.4}/setup.cfg +0 -0
|
@@ -5,7 +5,6 @@ A Python client library for the PlanSolve optimization API.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from .client import PlanSolveClient
|
|
8
|
-
from .field_service.constraint_weight import ConstraintWeight
|
|
9
8
|
from .field_service.api_client import FieldServiceApiClient
|
|
10
9
|
from .professional_services.api_client import ProfessionalServicesApiClient
|
|
11
10
|
|
|
@@ -16,7 +15,6 @@ from .field_service.models import (
|
|
|
16
15
|
Vehicle,
|
|
17
16
|
Visit,
|
|
18
17
|
SolverOptions,
|
|
19
|
-
Weights,
|
|
20
18
|
FieldServiceRequest,
|
|
21
19
|
ScheduledVehicle,
|
|
22
20
|
ScheduledVisit,
|
|
@@ -35,20 +33,18 @@ from .professional_services.models import (
|
|
|
35
33
|
ProfessionalServicesResultResponse,
|
|
36
34
|
)
|
|
37
35
|
|
|
38
|
-
__version__ = "0.25.
|
|
36
|
+
__version__ = "0.25.4"
|
|
39
37
|
|
|
40
38
|
__all__ = [
|
|
41
39
|
"PlanSolveClient",
|
|
42
40
|
"FieldServiceApiClient",
|
|
43
41
|
"ProfessionalServicesApiClient",
|
|
44
|
-
"ConstraintWeight",
|
|
45
42
|
# Field Service Models
|
|
46
43
|
"Shift",
|
|
47
44
|
"TimeWindow",
|
|
48
45
|
"Vehicle",
|
|
49
46
|
"Visit",
|
|
50
47
|
"SolverOptions",
|
|
51
|
-
"Weights",
|
|
52
48
|
"FieldServiceRequest",
|
|
53
49
|
"ScheduledVehicle",
|
|
54
50
|
"ScheduledVisit",
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""HTTP error handling helpers for the PlanSolve SDK.
|
|
2
|
+
|
|
3
|
+
The PlanSolve API documents three distinct error body shapes:
|
|
4
|
+
|
|
5
|
+
- ``ValidationProblemDetails`` (400)::
|
|
6
|
+
|
|
7
|
+
{"type", "title", "status", "detail", "instance",
|
|
8
|
+
"errors": {"<field>": ["<msg>", ...]}}
|
|
9
|
+
|
|
10
|
+
- ``ErrorResponse`` (402/422)::
|
|
11
|
+
|
|
12
|
+
{"error": "<string>"}
|
|
13
|
+
|
|
14
|
+
- ``ProblemDetails`` (401/403/502)::
|
|
15
|
+
|
|
16
|
+
{"type", "title", "status", "detail", "instance"}
|
|
17
|
+
|
|
18
|
+
``extract_error_message`` turns any of these into a short human-readable
|
|
19
|
+
string instead of dumping the raw JSON body at the caller.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def extract_error_message(body: str) -> str:
|
|
28
|
+
"""Extract a clean, human-readable message from an HTTP error body.
|
|
29
|
+
|
|
30
|
+
Priority order (highest first): validation ``errors`` > ``error`` >
|
|
31
|
+
``detail`` > ``title`` > the raw body.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
body: The raw response body text.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A concise error message derived from the body, or the raw body when
|
|
38
|
+
no recognized field is present or the body is not valid JSON.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
parsed = json.loads(body)
|
|
42
|
+
except (ValueError, TypeError):
|
|
43
|
+
return body
|
|
44
|
+
|
|
45
|
+
if isinstance(parsed, dict):
|
|
46
|
+
errors = parsed.get("errors")
|
|
47
|
+
if isinstance(errors, dict):
|
|
48
|
+
messages = []
|
|
49
|
+
for field, msgs in errors.items():
|
|
50
|
+
if isinstance(msgs, list):
|
|
51
|
+
for msg in msgs:
|
|
52
|
+
messages.append(f"{field}: {msg}")
|
|
53
|
+
messages.sort()
|
|
54
|
+
if messages:
|
|
55
|
+
return "; ".join(messages)
|
|
56
|
+
|
|
57
|
+
error = parsed.get("error")
|
|
58
|
+
if isinstance(error, str) and error:
|
|
59
|
+
return error
|
|
60
|
+
|
|
61
|
+
detail = parsed.get("detail")
|
|
62
|
+
if isinstance(detail, str) and detail:
|
|
63
|
+
return detail
|
|
64
|
+
|
|
65
|
+
title = parsed.get("title")
|
|
66
|
+
if isinstance(title, str) and title:
|
|
67
|
+
return title
|
|
68
|
+
|
|
69
|
+
return body
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def raise_for_status(response: requests.Response) -> None:
|
|
73
|
+
"""Raise ``requests.HTTPError`` with a clean message for non-2xx responses.
|
|
74
|
+
|
|
75
|
+
Behaves like ``response.raise_for_status()`` but replaces the raw JSON body
|
|
76
|
+
in the exception message with the output of :func:`extract_error_message`,
|
|
77
|
+
while preserving the HTTP status code.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
response: The response to validate.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
requests.HTTPError: If the response has a 4xx or 5xx status code.
|
|
84
|
+
"""
|
|
85
|
+
if response.status_code < 400:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
message = extract_error_message(response.text)
|
|
89
|
+
raise requests.HTTPError(
|
|
90
|
+
f"HTTP {response.status_code}: {message}", response=response
|
|
91
|
+
)
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
"""Field service optimization module."""
|
|
2
2
|
|
|
3
3
|
from .api_client import FieldServiceApiClient
|
|
4
|
-
from .constraint_weight import ConstraintWeight
|
|
5
4
|
from .models import (
|
|
6
5
|
Shift,
|
|
7
6
|
TimeWindow,
|
|
8
7
|
Vehicle,
|
|
9
8
|
Visit,
|
|
10
9
|
SolverOptions,
|
|
11
|
-
Weights,
|
|
12
10
|
FieldServiceRequest,
|
|
13
11
|
ScheduledVehicle,
|
|
14
12
|
ScheduledVisit,
|
|
@@ -18,17 +16,14 @@ from .models import (
|
|
|
18
16
|
|
|
19
17
|
__all__ = [
|
|
20
18
|
"FieldServiceApiClient",
|
|
21
|
-
"ConstraintWeight",
|
|
22
19
|
"Shift",
|
|
23
20
|
"TimeWindow",
|
|
24
21
|
"Vehicle",
|
|
25
22
|
"Visit",
|
|
26
23
|
"SolverOptions",
|
|
27
|
-
"Weights",
|
|
28
24
|
"FieldServiceRequest",
|
|
29
25
|
"ScheduledVehicle",
|
|
30
26
|
"ScheduledVisit",
|
|
31
27
|
"FieldServiceResponse",
|
|
32
28
|
"FieldServiceResultResponse",
|
|
33
29
|
]
|
|
34
|
-
|
|
@@ -5,6 +5,7 @@ from typing import Any, Dict, Optional, Union
|
|
|
5
5
|
|
|
6
6
|
import requests
|
|
7
7
|
|
|
8
|
+
from ..errors import raise_for_status
|
|
8
9
|
from ..routes import Routes
|
|
9
10
|
from ..solver_status import SOLVER_STATUS, SolverStatusResponse
|
|
10
11
|
from .models import (
|
|
@@ -58,7 +59,7 @@ class FieldServiceApiClient:
|
|
|
58
59
|
response = requests.post(
|
|
59
60
|
self.base_url, json=api_request, headers=self._get_headers()
|
|
60
61
|
)
|
|
61
|
-
|
|
62
|
+
raise_for_status(response)
|
|
62
63
|
return FieldServiceResponse.from_dict(response.json())
|
|
63
64
|
|
|
64
65
|
def get_result(self, job_id: str) -> FieldServiceResultResponse:
|
|
@@ -79,7 +80,7 @@ class FieldServiceApiClient:
|
|
|
79
80
|
if self.api_key:
|
|
80
81
|
headers["X-API-KEY"] = self.api_key
|
|
81
82
|
response = requests.get(url, headers=headers)
|
|
82
|
-
|
|
83
|
+
raise_for_status(response)
|
|
83
84
|
return FieldServiceResultResponse.from_dict(response.json())
|
|
84
85
|
|
|
85
86
|
def get_status(self, job_id: str) -> SolverStatusResponse:
|
|
@@ -100,7 +101,7 @@ class FieldServiceApiClient:
|
|
|
100
101
|
if self.api_key:
|
|
101
102
|
headers["X-API-KEY"] = self.api_key
|
|
102
103
|
response = requests.get(url, headers=headers)
|
|
103
|
-
|
|
104
|
+
raise_for_status(response)
|
|
104
105
|
return response.json()
|
|
105
106
|
|
|
106
107
|
def analyze(self, job_id: str) -> Dict[str, Any]:
|
|
@@ -121,7 +122,7 @@ class FieldServiceApiClient:
|
|
|
121
122
|
if self.api_key:
|
|
122
123
|
headers["X-API-KEY"] = self.api_key
|
|
123
124
|
response = requests.get(url, headers=headers)
|
|
124
|
-
|
|
125
|
+
raise_for_status(response)
|
|
125
126
|
return response.json()
|
|
126
127
|
|
|
127
128
|
def start_and_wait_for_completion(
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
4
|
from typing import List, Optional, Tuple, Dict, Any, Literal
|
|
5
5
|
|
|
6
|
-
from .constraint_weight import ConstraintWeight
|
|
7
|
-
|
|
8
6
|
|
|
9
7
|
@dataclass
|
|
10
8
|
class Shift:
|
|
@@ -184,99 +182,16 @@ class SolverOptions:
|
|
|
184
182
|
)
|
|
185
183
|
|
|
186
184
|
|
|
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
185
|
@dataclass
|
|
274
186
|
class FieldServiceRequest:
|
|
275
187
|
"""Request for field service optimization."""
|
|
276
188
|
|
|
277
189
|
vehicles: List[Vehicle]
|
|
278
190
|
visits: List[Visit]
|
|
279
|
-
|
|
191
|
+
# Open map of constraint name -> score string, e.g.
|
|
192
|
+
# {"minimizeTravelTime": "0hard/0medium/1soft"}. Keys are arbitrary
|
|
193
|
+
# (mostly camelCase, but some capitalized) and the key set is open-ended.
|
|
194
|
+
weights: Optional[Dict[str, str]] = None
|
|
280
195
|
options: Optional[SolverOptions] = None
|
|
281
196
|
|
|
282
197
|
def to_dict(self) -> Dict[str, Any]:
|
|
@@ -285,10 +200,8 @@ class FieldServiceRequest:
|
|
|
285
200
|
"vehicles": [v.to_dict() for v in self.vehicles],
|
|
286
201
|
"visits": [v.to_dict() for v in self.visits],
|
|
287
202
|
}
|
|
288
|
-
if self.weights
|
|
289
|
-
|
|
290
|
-
if weights_dict:
|
|
291
|
-
result["weights"] = weights_dict
|
|
203
|
+
if self.weights:
|
|
204
|
+
result["weights"] = {str(k): str(v) for k, v in self.weights.items()}
|
|
292
205
|
if self.options is not None:
|
|
293
206
|
options_dict = self.options.to_dict()
|
|
294
207
|
if options_dict:
|
|
@@ -298,10 +211,13 @@ class FieldServiceRequest:
|
|
|
298
211
|
@classmethod
|
|
299
212
|
def from_dict(cls, data: Dict[str, Any]) -> "FieldServiceRequest":
|
|
300
213
|
"""Create from dictionary."""
|
|
214
|
+
weights = data.get("weights")
|
|
301
215
|
return cls(
|
|
302
216
|
vehicles=[Vehicle.from_dict(v) for v in data["vehicles"]],
|
|
303
217
|
visits=[Visit.from_dict(v) for v in data["visits"]],
|
|
304
|
-
weights=
|
|
218
|
+
weights=(
|
|
219
|
+
{str(k): str(v) for k, v in weights.items()} if weights else None
|
|
220
|
+
),
|
|
305
221
|
options=(
|
|
306
222
|
SolverOptions.from_dict(data["options"])
|
|
307
223
|
if data.get("options")
|
|
@@ -5,6 +5,7 @@ from typing import Any, Dict, Optional, Union
|
|
|
5
5
|
|
|
6
6
|
import requests
|
|
7
7
|
|
|
8
|
+
from ..errors import raise_for_status
|
|
8
9
|
from ..routes import Routes
|
|
9
10
|
from ..solver_status import SOLVER_STATUS, SolverStatusResponse
|
|
10
11
|
from .models import (
|
|
@@ -58,7 +59,7 @@ class ProfessionalServicesApiClient:
|
|
|
58
59
|
response = requests.post(
|
|
59
60
|
self.base_url, json=api_request, headers=self._get_headers()
|
|
60
61
|
)
|
|
61
|
-
|
|
62
|
+
raise_for_status(response)
|
|
62
63
|
return ProfessionalServicesResponse.from_dict(response.json())
|
|
63
64
|
|
|
64
65
|
def get_result(self, job_id: str) -> ProfessionalServicesResultResponse:
|
|
@@ -79,7 +80,7 @@ class ProfessionalServicesApiClient:
|
|
|
79
80
|
if self.api_key:
|
|
80
81
|
headers["X-API-KEY"] = self.api_key
|
|
81
82
|
response = requests.get(url, headers=headers)
|
|
82
|
-
|
|
83
|
+
raise_for_status(response)
|
|
83
84
|
return ProfessionalServicesResultResponse.from_dict(response.json())
|
|
84
85
|
|
|
85
86
|
def get_status(self, job_id: str) -> SolverStatusResponse:
|
|
@@ -100,7 +101,7 @@ class ProfessionalServicesApiClient:
|
|
|
100
101
|
if self.api_key:
|
|
101
102
|
headers["X-API-KEY"] = self.api_key
|
|
102
103
|
response = requests.get(url, headers=headers)
|
|
103
|
-
|
|
104
|
+
raise_for_status(response)
|
|
104
105
|
return response.json()
|
|
105
106
|
|
|
106
107
|
def analyze(self, job_id: str) -> Dict[str, Any]:
|
|
@@ -121,7 +122,7 @@ class ProfessionalServicesApiClient:
|
|
|
121
122
|
if self.api_key:
|
|
122
123
|
headers["X-API-KEY"] = self.api_key
|
|
123
124
|
response = requests.get(url, headers=headers)
|
|
124
|
-
|
|
125
|
+
raise_for_status(response)
|
|
125
126
|
return response.json()
|
|
126
127
|
|
|
127
128
|
def start_and_wait_for_completion(
|
|
@@ -5,6 +5,7 @@ from typing import Any, Dict, Optional, Union
|
|
|
5
5
|
|
|
6
6
|
import requests
|
|
7
7
|
|
|
8
|
+
from ..errors import raise_for_status
|
|
8
9
|
from ..routes import Routes
|
|
9
10
|
from ..solver_status import SOLVER_STATUS, SolverStatusResponse
|
|
10
11
|
from .models import (
|
|
@@ -58,7 +59,7 @@ class ShiftApiClient:
|
|
|
58
59
|
response = requests.post(
|
|
59
60
|
self.base_url, json=api_request, headers=self._get_headers()
|
|
60
61
|
)
|
|
61
|
-
|
|
62
|
+
raise_for_status(response)
|
|
62
63
|
return ShiftResponse.from_dict(response.json())
|
|
63
64
|
|
|
64
65
|
def get_result(self, job_id: str) -> ShiftResultResponse:
|
|
@@ -79,7 +80,7 @@ class ShiftApiClient:
|
|
|
79
80
|
if self.api_key:
|
|
80
81
|
headers["X-API-KEY"] = self.api_key
|
|
81
82
|
response = requests.get(url, headers=headers)
|
|
82
|
-
|
|
83
|
+
raise_for_status(response)
|
|
83
84
|
return ShiftResultResponse.from_dict(response.json())
|
|
84
85
|
|
|
85
86
|
def get_status(self, job_id: str) -> SolverStatusResponse:
|
|
@@ -100,7 +101,7 @@ class ShiftApiClient:
|
|
|
100
101
|
if self.api_key:
|
|
101
102
|
headers["X-API-KEY"] = self.api_key
|
|
102
103
|
response = requests.get(url, headers=headers)
|
|
103
|
-
|
|
104
|
+
raise_for_status(response)
|
|
104
105
|
return response.json()
|
|
105
106
|
|
|
106
107
|
def analyze(self, job_id: str) -> Dict[str, Any]:
|
|
@@ -121,7 +122,7 @@ class ShiftApiClient:
|
|
|
121
122
|
if self.api_key:
|
|
122
123
|
headers["X-API-KEY"] = self.api_key
|
|
123
124
|
response = requests.get(url, headers=headers)
|
|
124
|
-
|
|
125
|
+
raise_for_status(response)
|
|
125
126
|
return response.json()
|
|
126
127
|
|
|
127
128
|
def start_and_wait_for_completion(
|
|
@@ -4,6 +4,7 @@ README.md
|
|
|
4
4
|
pyproject.toml
|
|
5
5
|
plansolve/__init__.py
|
|
6
6
|
plansolve/client.py
|
|
7
|
+
plansolve/errors.py
|
|
7
8
|
plansolve/py.typed
|
|
8
9
|
plansolve/routes.py
|
|
9
10
|
plansolve/solver_status.py
|
|
@@ -14,7 +15,6 @@ plansolve.egg-info/requires.txt
|
|
|
14
15
|
plansolve.egg-info/top_level.txt
|
|
15
16
|
plansolve/field_service/__init__.py
|
|
16
17
|
plansolve/field_service/api_client.py
|
|
17
|
-
plansolve/field_service/constraint_weight.py
|
|
18
18
|
plansolve/field_service/models.py
|
|
19
19
|
plansolve/professional_services/__init__.py
|
|
20
20
|
plansolve/professional_services/api_client.py
|
|
@@ -35,4 +35,6 @@ plansolve/shift/planning_period.py
|
|
|
35
35
|
plansolve/shift/shift_assignment.py
|
|
36
36
|
plansolve/shift/shift_employee.py
|
|
37
37
|
plansolve/shift/shift_off_request.py
|
|
38
|
-
plansolve/shift/weights.py
|
|
38
|
+
plansolve/shift/weights.py
|
|
39
|
+
tests/test_error_messages.py
|
|
40
|
+
tests/test_field_service_weights.py
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Tests for HTTP error message extraction."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from plansolve.errors import extract_error_message
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_validation_problem_details_joins_sorted_field_messages():
|
|
9
|
+
body = json.dumps(
|
|
10
|
+
{
|
|
11
|
+
"type": "https://httpstatuses.io/400",
|
|
12
|
+
"title": "One or more validation errors occurred.",
|
|
13
|
+
"status": 400,
|
|
14
|
+
"detail": "See the errors property for details.",
|
|
15
|
+
"instance": "/api/field-service",
|
|
16
|
+
"errors": {
|
|
17
|
+
"vehicles": ["The vehicles field is required.", "At least one vehicle is needed."],
|
|
18
|
+
"visits": ["The visits field is required."],
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
message = extract_error_message(body)
|
|
24
|
+
|
|
25
|
+
# Every field/message pair rendered as "field: msg", sorted ascending,
|
|
26
|
+
# joined with "; ". errors wins over detail/title.
|
|
27
|
+
assert message == (
|
|
28
|
+
"vehicles: At least one vehicle is needed.; "
|
|
29
|
+
"vehicles: The vehicles field is required.; "
|
|
30
|
+
"visits: The visits field is required."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_error_response_returns_error_string():
|
|
35
|
+
body = json.dumps({"error": "Quota exceeded for this API key."})
|
|
36
|
+
|
|
37
|
+
assert extract_error_message(body) == "Quota exceeded for this API key."
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_problem_details_returns_detail():
|
|
41
|
+
body = json.dumps(
|
|
42
|
+
{
|
|
43
|
+
"type": "https://httpstatuses.io/401",
|
|
44
|
+
"title": "Unauthorized",
|
|
45
|
+
"status": 401,
|
|
46
|
+
"detail": "The provided API key is invalid.",
|
|
47
|
+
"instance": "/api/field-service",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
assert extract_error_message(body) == "The provided API key is invalid."
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_problem_details_falls_back_to_title_when_no_detail():
|
|
55
|
+
body = json.dumps(
|
|
56
|
+
{
|
|
57
|
+
"type": "https://httpstatuses.io/403",
|
|
58
|
+
"title": "Forbidden",
|
|
59
|
+
"status": 403,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
assert extract_error_message(body) == "Forbidden"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_non_json_body_returns_raw_text():
|
|
67
|
+
body = "502 Bad Gateway"
|
|
68
|
+
|
|
69
|
+
assert extract_error_message(body) == "502 Bad Gateway"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_error_takes_priority_over_detail_and_title():
|
|
73
|
+
body = json.dumps({"error": "boom", "detail": "ignored", "title": "ignored"})
|
|
74
|
+
|
|
75
|
+
assert extract_error_message(body) == "boom"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_empty_errors_dict_falls_through_to_detail():
|
|
79
|
+
body = json.dumps({"errors": {}, "detail": "fallback detail"})
|
|
80
|
+
|
|
81
|
+
assert extract_error_message(body) == "fallback detail"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Tests for the field-service open-map weights model."""
|
|
2
|
+
|
|
3
|
+
from plansolve.field_service.models import FieldServiceRequest, Vehicle, Visit, Shift, TimeWindow
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _minimal_vehicle() -> Vehicle:
|
|
7
|
+
return Vehicle(
|
|
8
|
+
id="tech1",
|
|
9
|
+
location=(40.7128, -74.0060),
|
|
10
|
+
shifts=[
|
|
11
|
+
Shift(
|
|
12
|
+
id="morning-shift",
|
|
13
|
+
min_start_time="2024-01-15T08:00:00",
|
|
14
|
+
max_end_time="2024-01-15T17:00:00",
|
|
15
|
+
)
|
|
16
|
+
],
|
|
17
|
+
skills=["repair"],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _minimal_visit() -> Visit:
|
|
22
|
+
return Visit(
|
|
23
|
+
id="visit1",
|
|
24
|
+
name="Times Square Repair",
|
|
25
|
+
location=(40.7589, -73.9851),
|
|
26
|
+
time_windows=[
|
|
27
|
+
TimeWindow(
|
|
28
|
+
min_start_time="2024-01-15T09:00:00",
|
|
29
|
+
max_end_time="2024-01-15T17:00:00",
|
|
30
|
+
)
|
|
31
|
+
],
|
|
32
|
+
service_duration="PT30M",
|
|
33
|
+
priority="HIGH",
|
|
34
|
+
required_skills=["repair"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# The exact weights block from the PlanSolve API contract: an open string map
|
|
39
|
+
# with mostly camelCase keys plus a capitalized "PreferEarlierVisitDates" key.
|
|
40
|
+
EXAMPLE_WEIGHTS = {
|
|
41
|
+
"pinnedVisitVehicleAssignment": "1hard/0medium/0soft",
|
|
42
|
+
"vehicleUnassigned": "0hard/1medium/0soft",
|
|
43
|
+
"minimizeTravelTime": "0hard/0medium/1soft",
|
|
44
|
+
"PreferEarlierVisitDates": "0hard/0medium/20soft",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_weights_serialize_as_exact_string_map():
|
|
49
|
+
request = FieldServiceRequest(
|
|
50
|
+
vehicles=[_minimal_vehicle()],
|
|
51
|
+
visits=[_minimal_visit()],
|
|
52
|
+
weights=dict(EXAMPLE_WEIGHTS),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
payload = request.to_dict()
|
|
56
|
+
|
|
57
|
+
# Weights survive round-trip serialization exactly, preserving arbitrary
|
|
58
|
+
# and capitalized keys and the raw score strings.
|
|
59
|
+
assert payload["weights"] == EXAMPLE_WEIGHTS
|
|
60
|
+
assert "PreferEarlierVisitDates" in payload["weights"]
|
|
61
|
+
assert payload["weights"]["PreferEarlierVisitDates"] == "0hard/0medium/20soft"
|
|
62
|
+
|
|
63
|
+
# Every value must serialize as a plain JSON string, not a nested object.
|
|
64
|
+
for key, value in payload["weights"].items():
|
|
65
|
+
assert isinstance(key, str)
|
|
66
|
+
assert isinstance(value, str)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_weights_accept_arbitrary_unknown_keys():
|
|
70
|
+
weights = {"someBrandNewConstraint": "5hard/0medium/0soft"}
|
|
71
|
+
request = FieldServiceRequest(
|
|
72
|
+
vehicles=[_minimal_vehicle()],
|
|
73
|
+
visits=[_minimal_visit()],
|
|
74
|
+
weights=weights,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
assert request.to_dict()["weights"] == weights
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_weights_round_trip_from_dict():
|
|
81
|
+
data = {
|
|
82
|
+
"vehicles": [_minimal_vehicle().to_dict()],
|
|
83
|
+
"visits": [_minimal_visit().to_dict()],
|
|
84
|
+
"weights": dict(EXAMPLE_WEIGHTS),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
request = FieldServiceRequest.from_dict(data)
|
|
88
|
+
|
|
89
|
+
assert request.weights == EXAMPLE_WEIGHTS
|
|
90
|
+
assert request.to_dict()["weights"] == EXAMPLE_WEIGHTS
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_weights_omitted_when_none():
|
|
94
|
+
request = FieldServiceRequest(
|
|
95
|
+
vehicles=[_minimal_vehicle()],
|
|
96
|
+
visits=[_minimal_visit()],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
assert "weights" not in request.to_dict()
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
"""Constraint weight representation for field service optimization."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from typing import Optional
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class ConstraintWeight:
|
|
8
|
-
"""
|
|
9
|
-
Represents a constraint weight in the format "Xhard/Ymedium/Zsoft" where X, Y, Z are numeric values.
|
|
10
|
-
Only one of hard, medium, or soft can be non-zero at a time.
|
|
11
|
-
Examples: "1hard/0medium/0soft", "0hard/1medium/0soft", "0hard/0medium/1soft"
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
def __init__(self, hard: int, medium: int, soft: int):
|
|
15
|
-
"""
|
|
16
|
-
Create a new ConstraintWeight with the specified values.
|
|
17
|
-
Only one of hard, medium, or soft can be non-zero.
|
|
18
|
-
|
|
19
|
-
Args:
|
|
20
|
-
hard: Hard constraint weight value
|
|
21
|
-
medium: Medium constraint weight value
|
|
22
|
-
soft: Soft constraint weight value
|
|
23
|
-
|
|
24
|
-
Raises:
|
|
25
|
-
ValueError: When more than one value is non-zero
|
|
26
|
-
"""
|
|
27
|
-
non_zero_count = sum(1 for x in [hard, medium, soft] if x != 0)
|
|
28
|
-
if non_zero_count > 1:
|
|
29
|
-
raise ValueError(
|
|
30
|
-
f"Only one of hard, medium, or soft can be non-zero. "
|
|
31
|
-
f"Received: hard={hard}, medium={medium}, soft={soft}"
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
self.hard = hard
|
|
35
|
-
self.medium = medium
|
|
36
|
-
self.soft = soft
|
|
37
|
-
|
|
38
|
-
@staticmethod
|
|
39
|
-
def parse(value: str) -> "ConstraintWeight":
|
|
40
|
-
"""
|
|
41
|
-
Parse a weight string in the format "Xhard/Ymedium/Zsoft".
|
|
42
|
-
Only one of X, Y, or Z can be non-zero.
|
|
43
|
-
|
|
44
|
-
Args:
|
|
45
|
-
value: The weight string to parse
|
|
46
|
-
|
|
47
|
-
Returns:
|
|
48
|
-
A ConstraintWeight instance
|
|
49
|
-
|
|
50
|
-
Raises:
|
|
51
|
-
ValueError: When the string format is invalid or more than one value is non-zero
|
|
52
|
-
"""
|
|
53
|
-
if not value or not value.strip():
|
|
54
|
-
raise ValueError("Weight value cannot be null or empty")
|
|
55
|
-
|
|
56
|
-
# Pattern: (\d+)hard/(\d+)medium/(\d+)soft
|
|
57
|
-
pattern = r"^(\d+)hard/(\d+)medium/(\d+)soft$"
|
|
58
|
-
match = re.match(pattern, value, re.IGNORECASE)
|
|
59
|
-
|
|
60
|
-
if not match:
|
|
61
|
-
raise ValueError(
|
|
62
|
-
f"Invalid weight format: '{value}'. Expected format: 'Xhard/Ymedium/Zsoft'"
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
hard = int(match.group(1))
|
|
66
|
-
medium = int(match.group(2))
|
|
67
|
-
soft = int(match.group(3))
|
|
68
|
-
|
|
69
|
-
return ConstraintWeight(hard, medium, soft)
|
|
70
|
-
|
|
71
|
-
@staticmethod
|
|
72
|
-
def try_parse(value: Optional[str]) -> Optional["ConstraintWeight"]:
|
|
73
|
-
"""
|
|
74
|
-
Attempt to parse a weight string. Returns the ConstraintWeight if successful, None otherwise.
|
|
75
|
-
|
|
76
|
-
Args:
|
|
77
|
-
value: The weight string to parse, or None
|
|
78
|
-
|
|
79
|
-
Returns:
|
|
80
|
-
ConstraintWeight instance if successful, None otherwise
|
|
81
|
-
"""
|
|
82
|
-
if not value or not value.strip():
|
|
83
|
-
return None
|
|
84
|
-
|
|
85
|
-
try:
|
|
86
|
-
return ConstraintWeight.parse(value)
|
|
87
|
-
except (ValueError, AttributeError):
|
|
88
|
-
return None
|
|
89
|
-
|
|
90
|
-
def __str__(self) -> str:
|
|
91
|
-
"""
|
|
92
|
-
Return the string representation in the format "Xhard/Ymedium/Zsoft".
|
|
93
|
-
|
|
94
|
-
Returns:
|
|
95
|
-
String representation of the weight
|
|
96
|
-
"""
|
|
97
|
-
return f"{self.hard}hard/{self.medium}medium/{self.soft}soft"
|
|
98
|
-
|
|
99
|
-
def __repr__(self) -> str:
|
|
100
|
-
"""Return a string representation of the ConstraintWeight."""
|
|
101
|
-
return f"ConstraintWeight(hard={self.hard}, medium={self.medium}, soft={self.soft})"
|
|
102
|
-
|
|
103
|
-
@staticmethod
|
|
104
|
-
def create_hard(value: int) -> "ConstraintWeight":
|
|
105
|
-
"""
|
|
106
|
-
Create a hard constraint weight (Xhard/0medium/0soft).
|
|
107
|
-
|
|
108
|
-
Args:
|
|
109
|
-
value: The hard constraint value
|
|
110
|
-
|
|
111
|
-
Returns:
|
|
112
|
-
ConstraintWeight instance
|
|
113
|
-
"""
|
|
114
|
-
return ConstraintWeight(value, 0, 0)
|
|
115
|
-
|
|
116
|
-
@staticmethod
|
|
117
|
-
def create_medium(value: int) -> "ConstraintWeight":
|
|
118
|
-
"""
|
|
119
|
-
Create a medium constraint weight (0hard/Xmedium/0soft).
|
|
120
|
-
|
|
121
|
-
Args:
|
|
122
|
-
value: The medium constraint value
|
|
123
|
-
|
|
124
|
-
Returns:
|
|
125
|
-
ConstraintWeight instance
|
|
126
|
-
"""
|
|
127
|
-
return ConstraintWeight(0, value, 0)
|
|
128
|
-
|
|
129
|
-
@staticmethod
|
|
130
|
-
def create_soft(value: int) -> "ConstraintWeight":
|
|
131
|
-
"""
|
|
132
|
-
Create a soft constraint weight (0hard/0medium/Xsoft).
|
|
133
|
-
|
|
134
|
-
Args:
|
|
135
|
-
value: The soft constraint value
|
|
136
|
-
|
|
137
|
-
Returns:
|
|
138
|
-
ConstraintWeight instance
|
|
139
|
-
"""
|
|
140
|
-
return ConstraintWeight(0, 0, value)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{plansolve-0.25.2 → plansolve-0.25.4}/plansolve/professional_services/availability_time_span.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|