karrio-spring 2026.1.3__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.
- karrio/mappers/spring/__init__.py +3 -0
- karrio/mappers/spring/mapper.py +53 -0
- karrio/mappers/spring/proxy.py +71 -0
- karrio/mappers/spring/settings.py +33 -0
- karrio/plugins/spring/__init__.py +20 -0
- karrio/providers/spring/__init__.py +12 -0
- karrio/providers/spring/error.py +48 -0
- karrio/providers/spring/shipment/__init__.py +9 -0
- karrio/providers/spring/shipment/cancel.py +54 -0
- karrio/providers/spring/shipment/create.py +242 -0
- karrio/providers/spring/tracking.py +142 -0
- karrio/providers/spring/units.py +382 -0
- karrio/providers/spring/utils.py +34 -0
- karrio/schemas/spring/__init__.py +0 -0
- karrio/schemas/spring/error_response.py +9 -0
- karrio/schemas/spring/shipment_cancel_request.py +16 -0
- karrio/schemas/spring/shipment_cancel_response.py +16 -0
- karrio/schemas/spring/shipment_request.py +98 -0
- karrio/schemas/spring/shipment_response.py +75 -0
- karrio/schemas/spring/tracking_request.py +16 -0
- karrio/schemas/spring/tracking_response.py +49 -0
- karrio_spring-2026.1.3.dist-info/METADATA +44 -0
- karrio_spring-2026.1.3.dist-info/RECORD +26 -0
- karrio_spring-2026.1.3.dist-info/WHEEL +5 -0
- karrio_spring-2026.1.3.dist-info/entry_points.txt +2 -0
- karrio_spring-2026.1.3.dist-info/top_level.txt +3 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Karrio Spring client mapper."""
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
import karrio.lib as lib
|
|
5
|
+
import karrio.api.mapper as mapper
|
|
6
|
+
import karrio.core.models as models
|
|
7
|
+
import karrio.providers.spring as provider
|
|
8
|
+
import karrio.mappers.spring.settings as provider_settings
|
|
9
|
+
import karrio.universal.providers.rating as universal_provider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Mapper(mapper.Mapper):
|
|
13
|
+
settings: provider_settings.Settings
|
|
14
|
+
|
|
15
|
+
def create_rate_request(self, payload: models.RateRequest) -> lib.Serializable:
|
|
16
|
+
return universal_provider.rate_request(payload, self.settings)
|
|
17
|
+
|
|
18
|
+
def create_tracking_request(
|
|
19
|
+
self, payload: models.TrackingRequest
|
|
20
|
+
) -> lib.Serializable:
|
|
21
|
+
return provider.tracking_request(payload, self.settings)
|
|
22
|
+
|
|
23
|
+
def create_shipment_request(
|
|
24
|
+
self, payload: models.ShipmentRequest
|
|
25
|
+
) -> lib.Serializable:
|
|
26
|
+
return provider.shipment_request(payload, self.settings)
|
|
27
|
+
|
|
28
|
+
def create_cancel_shipment_request(
|
|
29
|
+
self, payload: models.ShipmentCancelRequest
|
|
30
|
+
) -> lib.Serializable[str]:
|
|
31
|
+
return provider.shipment_cancel_request(payload, self.settings)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_cancel_shipment_response(
|
|
35
|
+
self, response: lib.Deserializable[str]
|
|
36
|
+
) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]:
|
|
37
|
+
return provider.parse_shipment_cancel_response(response, self.settings)
|
|
38
|
+
|
|
39
|
+
def parse_rate_response(
|
|
40
|
+
self, response: lib.Deserializable[str]
|
|
41
|
+
) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]:
|
|
42
|
+
return universal_provider.parse_rate_response(response, self.settings)
|
|
43
|
+
|
|
44
|
+
def parse_shipment_response(
|
|
45
|
+
self, response: lib.Deserializable[str]
|
|
46
|
+
) -> typing.Tuple[models.ShipmentDetails, typing.List[models.Message]]:
|
|
47
|
+
return provider.parse_shipment_response(response, self.settings)
|
|
48
|
+
|
|
49
|
+
def parse_tracking_response(
|
|
50
|
+
self, response: lib.Deserializable[str]
|
|
51
|
+
) -> typing.Tuple[typing.List[models.TrackingDetails], typing.List[models.Message]]:
|
|
52
|
+
return provider.parse_tracking_response(response, self.settings)
|
|
53
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Karrio Spring client proxy."""
|
|
2
|
+
|
|
3
|
+
import karrio.lib as lib
|
|
4
|
+
import karrio.api.proxy as proxy
|
|
5
|
+
import karrio.mappers.spring.settings as provider_settings
|
|
6
|
+
import karrio.universal.mappers.rating_proxy as rating_proxy
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Proxy(rating_proxy.RatingMixinProxy, proxy.Proxy):
|
|
10
|
+
settings: provider_settings.Settings
|
|
11
|
+
|
|
12
|
+
def get_rates(self, request: lib.Serializable) -> lib.Deserializable[str]:
|
|
13
|
+
return super().get_rates(request)
|
|
14
|
+
|
|
15
|
+
def create_shipment(self, request: lib.Serializable) -> lib.Deserializable[str]:
|
|
16
|
+
"""Create shipments using Spring OrderShipment API.
|
|
17
|
+
|
|
18
|
+
Spring is a per-package carrier, so we make parallel requests
|
|
19
|
+
for each package and return a list of responses.
|
|
20
|
+
"""
|
|
21
|
+
responses = lib.run_asynchronously(
|
|
22
|
+
lambda req: lib.request(
|
|
23
|
+
url=self.settings.server_url,
|
|
24
|
+
data=lib.to_json(req),
|
|
25
|
+
trace=self.trace_as("json"),
|
|
26
|
+
method="POST",
|
|
27
|
+
headers={"Content-Type": "text/json"},
|
|
28
|
+
),
|
|
29
|
+
request.serialize(),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return lib.Deserializable(
|
|
33
|
+
responses,
|
|
34
|
+
lambda __: [lib.to_dict(res) for res in __],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def cancel_shipment(self, request: lib.Serializable) -> lib.Deserializable[str]:
|
|
38
|
+
response = lib.request(
|
|
39
|
+
url=self.settings.server_url,
|
|
40
|
+
data=lib.to_json(request.serialize()),
|
|
41
|
+
trace=self.trace_as("json"),
|
|
42
|
+
method="POST",
|
|
43
|
+
headers={"Content-Type": "text/json"},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return lib.Deserializable(response, lib.to_dict)
|
|
47
|
+
|
|
48
|
+
def get_tracking(self, request: lib.Serializable) -> lib.Deserializable[str]:
|
|
49
|
+
"""Track shipments using Spring TrackShipment API.
|
|
50
|
+
|
|
51
|
+
Spring tracks one shipment at a time, so we make parallel requests
|
|
52
|
+
for each tracking number and return results as (tracking_number, response) tuples.
|
|
53
|
+
"""
|
|
54
|
+
responses = lib.run_asynchronously(
|
|
55
|
+
lambda req: (
|
|
56
|
+
req["Shipment"]["TrackingNumber"],
|
|
57
|
+
lib.request(
|
|
58
|
+
url=self.settings.server_url,
|
|
59
|
+
data=lib.to_json(req),
|
|
60
|
+
trace=self.trace_as("json"),
|
|
61
|
+
method="POST",
|
|
62
|
+
headers={"Content-Type": "text/json"},
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
request.serialize(),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return lib.Deserializable(
|
|
69
|
+
responses,
|
|
70
|
+
lambda __: [(tracking_number, lib.to_dict(res)) for tracking_number, res in __],
|
|
71
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Karrio Spring client settings."""
|
|
2
|
+
|
|
3
|
+
import attr
|
|
4
|
+
import typing
|
|
5
|
+
import jstruct
|
|
6
|
+
import karrio.core.models as models
|
|
7
|
+
import karrio.providers.spring.units as provider_units
|
|
8
|
+
import karrio.providers.spring.utils as provider_utils
|
|
9
|
+
import karrio.universal.mappers.rating_proxy as rating_proxy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@attr.s(auto_attribs=True)
|
|
13
|
+
class Settings(provider_utils.Settings, rating_proxy.RatingMixinSettings):
|
|
14
|
+
"""Spring connection settings."""
|
|
15
|
+
|
|
16
|
+
# Carrier-specific credentials
|
|
17
|
+
api_key: str
|
|
18
|
+
|
|
19
|
+
# generic properties
|
|
20
|
+
id: str = None
|
|
21
|
+
test_mode: bool = False
|
|
22
|
+
carrier_id: str = "spring"
|
|
23
|
+
services: typing.List[models.ServiceLevel] = jstruct.JList[models.ServiceLevel, False, dict(default=provider_units.DEFAULT_SERVICES)] # type: ignore
|
|
24
|
+
account_country_code: str = None
|
|
25
|
+
metadata: dict = {}
|
|
26
|
+
config: dict = {}
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def shipping_services(self) -> typing.List[models.ServiceLevel]:
|
|
30
|
+
if any(self.services or []):
|
|
31
|
+
return self.services
|
|
32
|
+
|
|
33
|
+
return provider_units.DEFAULT_SERVICES
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import karrio.core.metadata as metadata
|
|
2
|
+
import karrio.mappers.spring as mappers
|
|
3
|
+
import karrio.providers.spring.units as units
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
METADATA = metadata.PluginMetadata(
|
|
7
|
+
status="beta",
|
|
8
|
+
id="spring",
|
|
9
|
+
label="Spring",
|
|
10
|
+
# Integrations
|
|
11
|
+
Mapper=mappers.Mapper,
|
|
12
|
+
Proxy=mappers.Proxy,
|
|
13
|
+
Settings=mappers.Settings,
|
|
14
|
+
# Data Units
|
|
15
|
+
is_hub=False,
|
|
16
|
+
options=units.ShippingOption,
|
|
17
|
+
services=units.ShippingService,
|
|
18
|
+
service_levels=units.DEFAULT_SERVICES,
|
|
19
|
+
connection_configs=units.ConnectionConfig,
|
|
20
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Karrio Spring provider imports."""
|
|
2
|
+
from karrio.providers.spring.utils import Settings
|
|
3
|
+
from karrio.providers.spring.shipment import (
|
|
4
|
+
parse_shipment_cancel_response,
|
|
5
|
+
parse_shipment_response,
|
|
6
|
+
shipment_cancel_request,
|
|
7
|
+
shipment_request,
|
|
8
|
+
)
|
|
9
|
+
from karrio.providers.spring.tracking import (
|
|
10
|
+
parse_tracking_response,
|
|
11
|
+
tracking_request,
|
|
12
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Karrio Spring error parser."""
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
import karrio.lib as lib
|
|
5
|
+
import karrio.core.models as models
|
|
6
|
+
import karrio.providers.spring.utils as provider_utils
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_error_response(
|
|
10
|
+
response: dict,
|
|
11
|
+
settings: provider_utils.Settings,
|
|
12
|
+
**kwargs,
|
|
13
|
+
) -> typing.List[models.Message]:
|
|
14
|
+
"""Parse Spring API error response.
|
|
15
|
+
|
|
16
|
+
Spring API uses ErrorLevel codes:
|
|
17
|
+
- 0: Command successfully completed without any errors
|
|
18
|
+
- 1: Command completed with errors (e.g., shipment created with errors)
|
|
19
|
+
- 10: Fatal error, command is not completed at all
|
|
20
|
+
"""
|
|
21
|
+
errors: typing.List[models.Message] = []
|
|
22
|
+
|
|
23
|
+
if not isinstance(response, dict):
|
|
24
|
+
return errors
|
|
25
|
+
|
|
26
|
+
error_level = response.get("ErrorLevel")
|
|
27
|
+
error_message = response.get("Error") or ""
|
|
28
|
+
|
|
29
|
+
# ErrorLevel 0 means success, no errors
|
|
30
|
+
if error_level == 0:
|
|
31
|
+
return errors
|
|
32
|
+
|
|
33
|
+
# ErrorLevel 1 or 10 indicates an error
|
|
34
|
+
if error_level in (1, 10):
|
|
35
|
+
errors.append(
|
|
36
|
+
models.Message(
|
|
37
|
+
carrier_id=settings.carrier_id,
|
|
38
|
+
carrier_name=settings.carrier_name,
|
|
39
|
+
code=str(error_level),
|
|
40
|
+
message=error_message or (
|
|
41
|
+
"Command completed with errors" if error_level == 1
|
|
42
|
+
else "Fatal error, command not completed"
|
|
43
|
+
),
|
|
44
|
+
details={**kwargs},
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return errors
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Karrio Spring shipment cancellation API implementation."""
|
|
2
|
+
|
|
3
|
+
import karrio.schemas.spring.shipment_cancel_request as spring_req
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
import karrio.lib as lib
|
|
7
|
+
import karrio.core.models as models
|
|
8
|
+
import karrio.providers.spring.error as error
|
|
9
|
+
import karrio.providers.spring.utils as provider_utils
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_shipment_cancel_response(
|
|
13
|
+
_response: lib.Deserializable[dict],
|
|
14
|
+
settings: provider_utils.Settings,
|
|
15
|
+
) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]:
|
|
16
|
+
"""Parse VoidShipment response from Spring API."""
|
|
17
|
+
response = _response.deserialize()
|
|
18
|
+
messages = error.parse_error_response(response, settings)
|
|
19
|
+
|
|
20
|
+
# ErrorLevel 0 means success
|
|
21
|
+
success = response.get("ErrorLevel") == 0 and not any(messages)
|
|
22
|
+
|
|
23
|
+
confirmation = (
|
|
24
|
+
models.ConfirmationDetails(
|
|
25
|
+
carrier_id=settings.carrier_id,
|
|
26
|
+
carrier_name=settings.carrier_name,
|
|
27
|
+
operation="Cancel Shipment",
|
|
28
|
+
success=success,
|
|
29
|
+
)
|
|
30
|
+
if success
|
|
31
|
+
else None
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return confirmation, messages
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def shipment_cancel_request(
|
|
38
|
+
payload: models.ShipmentCancelRequest,
|
|
39
|
+
settings: provider_utils.Settings,
|
|
40
|
+
) -> lib.Serializable:
|
|
41
|
+
"""Create a VoidShipment request for Spring API."""
|
|
42
|
+
|
|
43
|
+
# Spring API allows either TrackingNumber or ShipperReference
|
|
44
|
+
# payload.shipment_identifier is used as TrackingNumber
|
|
45
|
+
request = spring_req.ShipmentCancelRequestType(
|
|
46
|
+
Apikey=settings.api_key,
|
|
47
|
+
Command="VoidShipment",
|
|
48
|
+
Shipment=spring_req.ShipmentType(
|
|
49
|
+
TrackingNumber=payload.shipment_identifier,
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return lib.Serializable(request, lib.to_dict)
|
|
54
|
+
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Karrio Spring shipment API implementation."""
|
|
2
|
+
|
|
3
|
+
import karrio.schemas.spring.shipment_request as spring_req
|
|
4
|
+
import karrio.schemas.spring.shipment_response as spring_res
|
|
5
|
+
|
|
6
|
+
import uuid
|
|
7
|
+
import typing
|
|
8
|
+
import karrio.lib as lib
|
|
9
|
+
import karrio.core.units as units
|
|
10
|
+
import karrio.core.models as models
|
|
11
|
+
import karrio.providers.spring.error as error
|
|
12
|
+
import karrio.providers.spring.utils as provider_utils
|
|
13
|
+
import karrio.providers.spring.units as provider_units
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_shipment_response(
|
|
17
|
+
_response: lib.Deserializable[typing.List[dict]],
|
|
18
|
+
settings: provider_utils.Settings,
|
|
19
|
+
) -> typing.Tuple[typing.Optional[models.ShipmentDetails], typing.List[models.Message]]:
|
|
20
|
+
"""Parse shipment responses from Spring API.
|
|
21
|
+
|
|
22
|
+
Spring is a per-package carrier, so we receive a list of responses
|
|
23
|
+
(one per package) and aggregate them using lib.to_multi_piece_shipment().
|
|
24
|
+
"""
|
|
25
|
+
responses = _response.deserialize()
|
|
26
|
+
|
|
27
|
+
# Collect all error messages from all responses
|
|
28
|
+
messages: typing.List[models.Message] = sum(
|
|
29
|
+
[error.parse_error_response(response, settings) for response in responses],
|
|
30
|
+
start=[],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Extract shipment details for successful responses
|
|
34
|
+
shipment_details = [
|
|
35
|
+
(
|
|
36
|
+
f"{index}",
|
|
37
|
+
(
|
|
38
|
+
_extract_details(response, settings)
|
|
39
|
+
if response.get("Shipment") and response.get("ErrorLevel") in (0, 1)
|
|
40
|
+
else None
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
for index, response in enumerate(responses, start=1)
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Aggregate multi-piece shipment
|
|
47
|
+
shipment = lib.to_multi_piece_shipment(shipment_details)
|
|
48
|
+
|
|
49
|
+
return shipment, messages
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _extract_details(
|
|
53
|
+
data: dict,
|
|
54
|
+
settings: provider_utils.Settings,
|
|
55
|
+
) -> models.ShipmentDetails:
|
|
56
|
+
"""Extract shipment details from Spring API response."""
|
|
57
|
+
response = lib.to_object(spring_res.ShipmentResponseType, data)
|
|
58
|
+
shipment = response.Shipment
|
|
59
|
+
|
|
60
|
+
# Get tracking number - used as shipment_identifier for cancel operations
|
|
61
|
+
tracking_number = shipment.TrackingNumber
|
|
62
|
+
|
|
63
|
+
# Get label data - label_format defaults to PDF if not specified
|
|
64
|
+
label_format = shipment.LabelFormat or "PDF"
|
|
65
|
+
|
|
66
|
+
# Build documents object
|
|
67
|
+
documents = models.Documents(label=shipment.LabelImage)
|
|
68
|
+
|
|
69
|
+
return models.ShipmentDetails(
|
|
70
|
+
carrier_id=settings.carrier_id,
|
|
71
|
+
carrier_name=settings.carrier_name,
|
|
72
|
+
tracking_number=tracking_number,
|
|
73
|
+
shipment_identifier=tracking_number,
|
|
74
|
+
label_type=label_format,
|
|
75
|
+
docs=documents,
|
|
76
|
+
meta=dict(
|
|
77
|
+
service=shipment.Service,
|
|
78
|
+
carrier=shipment.Carrier,
|
|
79
|
+
shipper_reference=shipment.ShipperReference,
|
|
80
|
+
carrier_tracking_number=shipment.CarrierTrackingNumber,
|
|
81
|
+
carrier_local_tracking_number=shipment.CarrierLocalTrackingNumber,
|
|
82
|
+
carrier_tracking_url=shipment.CarrierTrackingUrl,
|
|
83
|
+
display_id=shipment.DisplayId,
|
|
84
|
+
label_type=shipment.LabelType,
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def shipment_request(
|
|
90
|
+
payload: models.ShipmentRequest,
|
|
91
|
+
settings: provider_utils.Settings,
|
|
92
|
+
) -> lib.Serializable:
|
|
93
|
+
"""Create Spring OrderShipment requests.
|
|
94
|
+
|
|
95
|
+
Spring is a per-package carrier, so we create one request per package
|
|
96
|
+
and return a list of requests to be processed in parallel.
|
|
97
|
+
"""
|
|
98
|
+
shipper = lib.to_address(payload.shipper)
|
|
99
|
+
recipient = lib.to_address(payload.recipient)
|
|
100
|
+
packages = lib.to_packages(payload.parcels)
|
|
101
|
+
service = provider_units.ShippingService.map(payload.service).value_or_key
|
|
102
|
+
options = lib.to_shipping_options(
|
|
103
|
+
payload.options,
|
|
104
|
+
package_options=packages.options,
|
|
105
|
+
initializer=provider_units.shipping_options_initializer,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Get customs info for international shipments
|
|
109
|
+
customs = lib.to_customs_info(
|
|
110
|
+
payload.customs,
|
|
111
|
+
shipper=shipper,
|
|
112
|
+
recipient=recipient,
|
|
113
|
+
weight_unit=units.WeightUnit.KG.name,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Determine label format from settings or payload
|
|
117
|
+
label_format = (
|
|
118
|
+
provider_units.LabelFormat.map(payload.label_type).value
|
|
119
|
+
or settings.connection_config.label_format.state
|
|
120
|
+
or "PDF"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Build consignor (shipper) address (same for all packages)
|
|
124
|
+
consignor_address = spring_req.ConsignorAddressType(
|
|
125
|
+
Name=shipper.person_name or shipper.company_name,
|
|
126
|
+
Company=shipper.company_name,
|
|
127
|
+
AddressLine1=shipper.address_line1,
|
|
128
|
+
AddressLine2=shipper.address_line2,
|
|
129
|
+
AddressLine3=None,
|
|
130
|
+
City=shipper.city,
|
|
131
|
+
State=shipper.state_code,
|
|
132
|
+
Zip=shipper.postal_code,
|
|
133
|
+
Country=shipper.country_code,
|
|
134
|
+
Phone=shipper.phone_number,
|
|
135
|
+
Email=shipper.email,
|
|
136
|
+
Vat=options.spring_consignor_vat.state or shipper.tax_id,
|
|
137
|
+
Eori=options.spring_consignor_eori.state,
|
|
138
|
+
NlVat=options.spring_consignor_nl_vat.state,
|
|
139
|
+
EuEori=options.spring_consignor_eu_eori.state,
|
|
140
|
+
GbEori=options.spring_consignor_gb_eori.state,
|
|
141
|
+
Ioss=options.spring_consignor_ioss.state,
|
|
142
|
+
LocalTaxNumber=options.spring_consignor_local_tax_number.state,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Build consignee (recipient) address (same for all packages)
|
|
146
|
+
consignee_address = spring_req.AddressType(
|
|
147
|
+
Name=recipient.person_name or recipient.company_name,
|
|
148
|
+
Company=recipient.company_name,
|
|
149
|
+
AddressLine1=recipient.address_line1,
|
|
150
|
+
AddressLine2=recipient.address_line2,
|
|
151
|
+
AddressLine3=None,
|
|
152
|
+
City=recipient.city,
|
|
153
|
+
State=recipient.state_code,
|
|
154
|
+
Zip=recipient.postal_code,
|
|
155
|
+
Country=recipient.country_code,
|
|
156
|
+
Phone=recipient.phone_number,
|
|
157
|
+
Email=recipient.email,
|
|
158
|
+
Vat=recipient.tax_id,
|
|
159
|
+
PudoLocationId=options.spring_pudo_location_id.state,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Build products array (customs items) - same for all packages
|
|
163
|
+
products = [
|
|
164
|
+
spring_req.ProductType(
|
|
165
|
+
Description=lib.text(item.description or item.title, max=60),
|
|
166
|
+
Sku=item.sku,
|
|
167
|
+
HsCode=item.hs_code,
|
|
168
|
+
OriginCountry=item.origin_country or shipper.country_code,
|
|
169
|
+
Quantity=str(item.quantity or 1),
|
|
170
|
+
Value=str(item.value_amount or 0),
|
|
171
|
+
Weight=str(item.weight or 0),
|
|
172
|
+
)
|
|
173
|
+
for item in (customs.commodities if customs else [])
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
# Calculate total value from products or use declared value
|
|
177
|
+
total_value = (
|
|
178
|
+
sum(float(p.Value or 0) for p in products)
|
|
179
|
+
if products
|
|
180
|
+
else (customs.duty.declared_value if customs and customs.duty else None)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Get declaration type mapping
|
|
184
|
+
declaration_type = None
|
|
185
|
+
if customs and customs.content_type:
|
|
186
|
+
declaration_type = provider_units.CustomsContentType.map(
|
|
187
|
+
customs.content_type
|
|
188
|
+
).value
|
|
189
|
+
|
|
190
|
+
# Get customs duty type
|
|
191
|
+
customs_duty = options.spring_customs_duty.state or (
|
|
192
|
+
provider_units.CustomsDuty.map(customs.incoterm).value
|
|
193
|
+
if customs and customs.incoterm
|
|
194
|
+
else "DDU"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Generate base reference for multi-piece shipment
|
|
198
|
+
base_reference = payload.reference or str(uuid.uuid4().hex)
|
|
199
|
+
|
|
200
|
+
# Create one request per package
|
|
201
|
+
requests = [
|
|
202
|
+
spring_req.ShipmentRequestType(
|
|
203
|
+
Apikey=settings.api_key,
|
|
204
|
+
Command="OrderShipment",
|
|
205
|
+
Shipment=spring_req.ShipmentType(
|
|
206
|
+
LabelFormat=label_format,
|
|
207
|
+
# Append package index to reference for multi-piece tracking
|
|
208
|
+
ShipperReference=(
|
|
209
|
+
f"{base_reference}-{index + 1}"
|
|
210
|
+
if len(packages) > 1
|
|
211
|
+
else base_reference
|
|
212
|
+
),
|
|
213
|
+
OrderReference=options.spring_order_reference.state,
|
|
214
|
+
OrderDate=options.spring_order_date.state,
|
|
215
|
+
DisplayId=options.spring_display_id.state,
|
|
216
|
+
InvoiceNumber=options.spring_invoice_number.state,
|
|
217
|
+
Service=service,
|
|
218
|
+
Weight=str(package.weight.KG),
|
|
219
|
+
WeightUnit="kg",
|
|
220
|
+
Length=str(package.length.CM) if package.length.CM else None,
|
|
221
|
+
Width=str(package.width.CM) if package.width.CM else None,
|
|
222
|
+
Height=str(package.height.CM) if package.height.CM else None,
|
|
223
|
+
DimUnit="cm" if any([package.length.CM, package.width.CM, package.height.CM]) else None,
|
|
224
|
+
Value=str(total_value) if total_value else None,
|
|
225
|
+
ShippingValue=str(options.spring_shipping_value.state) if options.spring_shipping_value.state else None,
|
|
226
|
+
Currency=customs.duty.currency if customs and customs.duty else options.currency.state,
|
|
227
|
+
CustomsDuty=customs_duty,
|
|
228
|
+
Description=package.parcel.description or (customs.content_description if customs else None),
|
|
229
|
+
DeclarationType=declaration_type or options.spring_declaration_type.state,
|
|
230
|
+
DangerousGoods="Y" if options.spring_dangerous_goods.state else "N",
|
|
231
|
+
ExportCarrierName=options.spring_export_carrier_name.state,
|
|
232
|
+
ExportAwb=options.spring_export_awb.state,
|
|
233
|
+
ConsignorAddress=consignor_address,
|
|
234
|
+
ConsigneeAddress=consignee_address,
|
|
235
|
+
# Pass empty list instead of None to avoid jstruct [None] serialization bug
|
|
236
|
+
Products=products if products else [],
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
for index, package in enumerate(packages)
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
return lib.Serializable(requests, lambda reqs: [lib.to_dict(r) for r in reqs])
|