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,142 @@
|
|
|
1
|
+
"""Karrio Spring tracking API implementation."""
|
|
2
|
+
|
|
3
|
+
import karrio.schemas.spring.tracking_request as spring_req
|
|
4
|
+
import karrio.schemas.spring.tracking_response as spring_res
|
|
5
|
+
|
|
6
|
+
import typing
|
|
7
|
+
import karrio.lib as lib
|
|
8
|
+
import karrio.core.models as models
|
|
9
|
+
import karrio.providers.spring.error as error
|
|
10
|
+
import karrio.providers.spring.utils as provider_utils
|
|
11
|
+
import karrio.providers.spring.units as provider_units
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _match_status(code: str) -> typing.Optional[str]:
|
|
15
|
+
"""Match code against TrackingStatus enum values."""
|
|
16
|
+
if not code:
|
|
17
|
+
return None
|
|
18
|
+
for status in list(provider_units.TrackingStatus):
|
|
19
|
+
if code in status.value:
|
|
20
|
+
return status.name
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _match_reason(code: str) -> typing.Optional[str]:
|
|
25
|
+
"""Match code against TrackingIncidentReason enum values."""
|
|
26
|
+
if not code:
|
|
27
|
+
return None
|
|
28
|
+
for reason in list(provider_units.TrackingIncidentReason):
|
|
29
|
+
if code in reason.value:
|
|
30
|
+
return reason.name
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_tracking_response(
|
|
35
|
+
_response: lib.Deserializable[typing.List[typing.Tuple[str, dict]]],
|
|
36
|
+
settings: provider_utils.Settings,
|
|
37
|
+
) -> typing.Tuple[typing.List[models.TrackingDetails], typing.List[models.Message]]:
|
|
38
|
+
"""Parse TrackShipment responses from Spring API."""
|
|
39
|
+
responses = _response.deserialize()
|
|
40
|
+
|
|
41
|
+
messages: typing.List[models.Message] = sum(
|
|
42
|
+
[
|
|
43
|
+
error.parse_error_response(response, settings, tracking_number=tracking_number)
|
|
44
|
+
for tracking_number, response in responses
|
|
45
|
+
],
|
|
46
|
+
start=[],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Only extract details for successful responses (ErrorLevel 0)
|
|
50
|
+
tracking_details = [
|
|
51
|
+
_extract_details(response, settings, tracking_number)
|
|
52
|
+
for tracking_number, response in responses
|
|
53
|
+
if response.get("ErrorLevel") == 0 and response.get("Shipment")
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
return tracking_details, messages
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_details(
|
|
60
|
+
data: dict,
|
|
61
|
+
settings: provider_utils.Settings,
|
|
62
|
+
tracking_number: str = None,
|
|
63
|
+
) -> models.TrackingDetails:
|
|
64
|
+
"""Extract tracking details from Spring API response."""
|
|
65
|
+
response = lib.to_object(spring_res.TrackingResponseType, data)
|
|
66
|
+
shipment = response.Shipment
|
|
67
|
+
|
|
68
|
+
# Get events and reverse to have most recent first
|
|
69
|
+
events = list(reversed(shipment.Events or []))
|
|
70
|
+
|
|
71
|
+
# Get latest event code for status mapping
|
|
72
|
+
latest_code = str(events[0].Code) if events else None
|
|
73
|
+
|
|
74
|
+
# Map carrier status to karrio standard tracking status
|
|
75
|
+
status = _match_status(latest_code) or provider_units.TrackingStatus.in_transit.name
|
|
76
|
+
|
|
77
|
+
# Build tracking events with all required fields per CARRIER_INTEGRATION_GUIDE.md
|
|
78
|
+
tracking_events = [
|
|
79
|
+
models.TrackingEvent(
|
|
80
|
+
date=lib.fdate(event.DateTime, "%Y-%m-%d %H:%M:%S"),
|
|
81
|
+
description=event.Description,
|
|
82
|
+
code=str(event.Code) if event.Code else None,
|
|
83
|
+
time=lib.flocaltime(event.DateTime, "%Y-%m-%d %H:%M:%S"),
|
|
84
|
+
location=lib.join(event.City, event.State, event.Country, join=True, separator=", "),
|
|
85
|
+
# REQUIRED: timestamp in ISO 8601 format
|
|
86
|
+
timestamp=lib.fiso_timestamp(
|
|
87
|
+
event.DateTime,
|
|
88
|
+
current_format="%Y-%m-%d %H:%M:%S",
|
|
89
|
+
),
|
|
90
|
+
# REQUIRED: normalized status at event level
|
|
91
|
+
status=_match_status(str(event.Code)),
|
|
92
|
+
# Incident reason for exception events
|
|
93
|
+
reason=_match_reason(str(event.Code)),
|
|
94
|
+
)
|
|
95
|
+
for event in events
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
return models.TrackingDetails(
|
|
99
|
+
carrier_id=settings.carrier_id,
|
|
100
|
+
carrier_name=settings.carrier_name,
|
|
101
|
+
tracking_number=shipment.TrackingNumber or tracking_number,
|
|
102
|
+
events=tracking_events,
|
|
103
|
+
delivered=status == "delivered",
|
|
104
|
+
status=status,
|
|
105
|
+
info=models.TrackingInfo(
|
|
106
|
+
carrier_tracking_link=shipment.CarrierTrackingUrl,
|
|
107
|
+
package_weight=shipment.Weight,
|
|
108
|
+
package_weight_unit=shipment.WeightUnit,
|
|
109
|
+
),
|
|
110
|
+
meta=dict(
|
|
111
|
+
service=shipment.Service,
|
|
112
|
+
carrier=shipment.Carrier,
|
|
113
|
+
display_id=shipment.DisplayId,
|
|
114
|
+
shipper_reference=shipment.ShipperReference,
|
|
115
|
+
carrier_tracking_number=shipment.CarrierTrackingNumber,
|
|
116
|
+
carrier_local_tracking_number=shipment.CarrierLocalTrackingNumber,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def tracking_request(
|
|
122
|
+
payload: models.TrackingRequest,
|
|
123
|
+
settings: provider_utils.Settings,
|
|
124
|
+
) -> lib.Serializable:
|
|
125
|
+
"""Create TrackShipment requests for Spring API.
|
|
126
|
+
|
|
127
|
+
Spring API tracks one shipment at a time, so we create a list of requests
|
|
128
|
+
for each tracking number.
|
|
129
|
+
"""
|
|
130
|
+
# Create individual requests for each tracking number using generated schema types
|
|
131
|
+
requests = [
|
|
132
|
+
spring_req.TrackingRequestType(
|
|
133
|
+
Apikey=settings.api_key,
|
|
134
|
+
Command="TrackShipment",
|
|
135
|
+
Shipment=spring_req.ShipmentType(
|
|
136
|
+
TrackingNumber=tracking_number,
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
for tracking_number in payload.tracking_numbers
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
return lib.Serializable(requests, lambda reqs: [lib.to_dict(r) for r in reqs])
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import pathlib
|
|
3
|
+
import karrio.lib as lib
|
|
4
|
+
import karrio.core.units as units
|
|
5
|
+
import karrio.core.models as models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConnectionConfig(lib.Enum):
|
|
9
|
+
"""Carrier connection configuration options."""
|
|
10
|
+
|
|
11
|
+
label_format = lib.OptionEnum("label_format", str, "PDF")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LabelFormat(lib.StrEnum):
|
|
15
|
+
"""Spring label formats."""
|
|
16
|
+
|
|
17
|
+
PDF = "PDF"
|
|
18
|
+
PNG = "PNG"
|
|
19
|
+
ZPL300 = "ZPL300" # ZPL 300 dpi
|
|
20
|
+
ZPL200 = "ZPL200" # ZPL 203 dpi
|
|
21
|
+
ZPL = "ZPL" # alias for ZPL300
|
|
22
|
+
EPL = "EPL" # EPL 203 dpi
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PackagingType(lib.StrEnum):
|
|
26
|
+
"""Carrier specific packaging type"""
|
|
27
|
+
|
|
28
|
+
PACKAGE = "PACKAGE"
|
|
29
|
+
|
|
30
|
+
""" Unified Packaging type mapping """
|
|
31
|
+
envelope = PACKAGE
|
|
32
|
+
pak = PACKAGE
|
|
33
|
+
tube = PACKAGE
|
|
34
|
+
pallet = PACKAGE
|
|
35
|
+
small_box = PACKAGE
|
|
36
|
+
medium_box = PACKAGE
|
|
37
|
+
your_packaging = PACKAGE
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CustomsContentType(lib.StrEnum):
|
|
41
|
+
"""Spring customs declaration types."""
|
|
42
|
+
|
|
43
|
+
sale_of_goods = "SaleOfGoods"
|
|
44
|
+
documents = "Documents"
|
|
45
|
+
gift = "Gift"
|
|
46
|
+
returned_goods = "ReturnedGoods"
|
|
47
|
+
commercial_sample = "CommercialSample"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CustomsDuty(lib.StrEnum):
|
|
51
|
+
"""Spring customs duty types."""
|
|
52
|
+
|
|
53
|
+
DDU = "DDU" # Delivered Duty Unpaid (default)
|
|
54
|
+
DDP = "DDP" # Delivered Duty Paid (Spring Clear)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ShippingService(lib.StrEnum):
|
|
58
|
+
"""Carrier specific services"""
|
|
59
|
+
|
|
60
|
+
# Routed services (auto-select best carrier based on destination/weight)
|
|
61
|
+
spring_tracked = "TRCK"
|
|
62
|
+
spring_signature = "SIGN"
|
|
63
|
+
spring_untracked = "UNTR"
|
|
64
|
+
spring_collect = "CLLCT"
|
|
65
|
+
|
|
66
|
+
# Express and special services
|
|
67
|
+
spring_express = "EXPR"
|
|
68
|
+
spring_import = "IMPRT"
|
|
69
|
+
spring_back_returns = "BACK"
|
|
70
|
+
spring_back_tracked = "BACKT"
|
|
71
|
+
spring_no_label = "NOLABEL"
|
|
72
|
+
|
|
73
|
+
# PostNL Parcel services
|
|
74
|
+
spring_postnl_parcel_eu = "PPLEU"
|
|
75
|
+
spring_postnl_parcel_benelux = "PPND"
|
|
76
|
+
spring_postnl_parcel_benelux_sign = "PPNDS"
|
|
77
|
+
spring_postnl_parcel_benelux_no_neighbor = "PPHD"
|
|
78
|
+
spring_postnl_parcel_benelux_sign_no_neighbor = "PPHDS"
|
|
79
|
+
spring_postnl_parcel_benelux_upu = "PPLUP"
|
|
80
|
+
spring_postnl_parcel_globalpack_ems = "PPLGE"
|
|
81
|
+
spring_postnl_parcel_globalpack_upu = "PPLGU"
|
|
82
|
+
spring_postnl_parcel_epg = "PPLEP"
|
|
83
|
+
spring_postnl_parcel_epg_noneu = "PPNEU"
|
|
84
|
+
spring_postnl_lightweight_china = "PPLLW"
|
|
85
|
+
spring_postnl_collect_service = "PPLCS"
|
|
86
|
+
|
|
87
|
+
# PostNL Packet services (< 2kg)
|
|
88
|
+
spring_postnl_packet_tracked = "PPTT"
|
|
89
|
+
spring_postnl_packet_registered = "PPTR"
|
|
90
|
+
spring_postnl_packet_non_tracked = "PPNT"
|
|
91
|
+
spring_postnl_packet_boxable_bag_trace = "PPBBT"
|
|
92
|
+
spring_postnl_packet_bag_trace = "PPBT"
|
|
93
|
+
spring_postnl_packet_boxable_tracked = "PPBTT"
|
|
94
|
+
spring_postnl_packet_boxable_non_tracked = "PPBNT"
|
|
95
|
+
|
|
96
|
+
# Royal Mail services
|
|
97
|
+
spring_royal_mail_tracked_24 = "RM24"
|
|
98
|
+
spring_royal_mail_tracked_24_sign = "RM24S"
|
|
99
|
+
spring_royal_mail_tracked_48 = "RM48"
|
|
100
|
+
spring_royal_mail_tracked_48_2 = "RM482"
|
|
101
|
+
spring_royal_mail_tracked_48_sign = "RM48S"
|
|
102
|
+
|
|
103
|
+
# Sending services (Spain/Portugal)
|
|
104
|
+
spring_sending_mainland = "SEND"
|
|
105
|
+
spring_sending_islands = "SEND2"
|
|
106
|
+
|
|
107
|
+
# Italian Post services
|
|
108
|
+
spring_italian_post_crono = "ITCR"
|
|
109
|
+
spring_italian_post_crono_express = "ITCRX"
|
|
110
|
+
|
|
111
|
+
# German services
|
|
112
|
+
spring_dpd_de = "DPDDE"
|
|
113
|
+
spring_hermes_sign = "HEHDS"
|
|
114
|
+
spring_hermes_collect = "HEDCS"
|
|
115
|
+
|
|
116
|
+
# French services
|
|
117
|
+
spring_colis_prive = "CPHD"
|
|
118
|
+
spring_colis_prive_sign = "CPHDS"
|
|
119
|
+
|
|
120
|
+
# Spring Commercial services
|
|
121
|
+
spring_com_standard = "SCST"
|
|
122
|
+
spring_com_standard_sign = "SCSTS"
|
|
123
|
+
spring_com_express = "SCEX"
|
|
124
|
+
spring_com_express_sign = "SCEXS"
|
|
125
|
+
|
|
126
|
+
# USA services
|
|
127
|
+
spring_usa_parcel_ground = "UPGR"
|
|
128
|
+
spring_usa_parcel_ground_sign = "UPGRS"
|
|
129
|
+
spring_usa_parcel_express = "UPEX"
|
|
130
|
+
spring_usa_parcel_express_sign = "UPEXS"
|
|
131
|
+
spring_usa_parcel_max = "UPMA"
|
|
132
|
+
spring_usa_parcel_max_sign = "UPMAS"
|
|
133
|
+
spring_usa_parcel_ground_dg = "UPDG"
|
|
134
|
+
spring_usa_parcel_ground_dg_sign = "UDGS"
|
|
135
|
+
spring_usa_parcel_plus_ground_dg = "UPPDG"
|
|
136
|
+
spring_usa_parcel_plus_ground_dg_sign = "UPDGS"
|
|
137
|
+
|
|
138
|
+
# Other carrier services
|
|
139
|
+
spring_packeta = "PACHD"
|
|
140
|
+
spring_mailalliance_boxable = "MABNT"
|
|
141
|
+
spring_austrian_post = "ATEHD"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ShippingOption(lib.Enum):
|
|
145
|
+
"""Carrier specific options"""
|
|
146
|
+
|
|
147
|
+
# Spring-specific options
|
|
148
|
+
spring_customs_duty = lib.OptionEnum("CustomsDuty")
|
|
149
|
+
spring_declaration_type = lib.OptionEnum("DeclarationType")
|
|
150
|
+
spring_dangerous_goods = lib.OptionEnum("DangerousGoods", bool)
|
|
151
|
+
spring_shipping_value = lib.OptionEnum("ShippingValue", float)
|
|
152
|
+
spring_display_id = lib.OptionEnum("DisplayId")
|
|
153
|
+
spring_invoice_number = lib.OptionEnum("InvoiceNumber")
|
|
154
|
+
spring_order_reference = lib.OptionEnum("OrderReference")
|
|
155
|
+
spring_order_date = lib.OptionEnum("OrderDate")
|
|
156
|
+
|
|
157
|
+
# Consignor tax/customs identifiers
|
|
158
|
+
spring_consignor_vat = lib.OptionEnum("ConsignorVat")
|
|
159
|
+
spring_consignor_eori = lib.OptionEnum("ConsignorEori")
|
|
160
|
+
spring_consignor_nl_vat = lib.OptionEnum("ConsignorNlVat")
|
|
161
|
+
spring_consignor_eu_eori = lib.OptionEnum("ConsignorEuEori")
|
|
162
|
+
spring_consignor_gb_eori = lib.OptionEnum("ConsignorGbEori")
|
|
163
|
+
spring_consignor_ioss = lib.OptionEnum("ConsignorIoss")
|
|
164
|
+
spring_consignor_local_tax_number = lib.OptionEnum("ConsignorLocalTaxNumber")
|
|
165
|
+
|
|
166
|
+
# Return label options (BACK service)
|
|
167
|
+
spring_export_carrier_name = lib.OptionEnum("ExportCarrierName")
|
|
168
|
+
spring_export_awb = lib.OptionEnum("ExportAwb")
|
|
169
|
+
|
|
170
|
+
# Collect service option
|
|
171
|
+
spring_pudo_location_id = lib.OptionEnum("PudoLocationId")
|
|
172
|
+
|
|
173
|
+
""" Unified Option type mapping """
|
|
174
|
+
dangerous_goods = spring_dangerous_goods
|
|
175
|
+
shipment_date = spring_order_date
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def shipping_options_initializer(
|
|
179
|
+
options: dict,
|
|
180
|
+
package_options: units.ShippingOptions = None,
|
|
181
|
+
) -> units.ShippingOptions:
|
|
182
|
+
"""
|
|
183
|
+
Apply default values to the given options.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
if package_options is not None:
|
|
187
|
+
options.update(package_options.content)
|
|
188
|
+
|
|
189
|
+
def items_filter(key: str) -> bool:
|
|
190
|
+
return key in ShippingOption # type: ignore
|
|
191
|
+
|
|
192
|
+
return units.ShippingOptions(options, ShippingOption, items_filter=items_filter)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TrackingStatus(lib.Enum):
|
|
196
|
+
"""
|
|
197
|
+
Spring tracking event codes mapped to Karrio unified statuses.
|
|
198
|
+
Based on Spring XBS API documentation section 3.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
pending = [
|
|
202
|
+
"0", # PARCEL CREATED
|
|
203
|
+
"12", # PREPARATION PROCESS
|
|
204
|
+
]
|
|
205
|
+
on_hold = [
|
|
206
|
+
"40", # IN CUSTOMS
|
|
207
|
+
"41", # CUSTOMS EXCEPTION
|
|
208
|
+
"31", # DELIVERY EXCEPTION – ACTION REQUIRED
|
|
209
|
+
]
|
|
210
|
+
delivered = [
|
|
211
|
+
"100", # DELIVERED
|
|
212
|
+
"101", # DELIVERED TO DESTINATION COUNTRY
|
|
213
|
+
]
|
|
214
|
+
in_transit = [
|
|
215
|
+
"15", # COLLECTION TRANSPORT
|
|
216
|
+
"18", # COLLECTION
|
|
217
|
+
"19", # PROCESSING DEPOT
|
|
218
|
+
"20", # ACCEPTED
|
|
219
|
+
"21", # INTERNATIONAL TRANSPORT
|
|
220
|
+
"22", # CROSSDOCK
|
|
221
|
+
"25", # END OF TRACKING UPDATES
|
|
222
|
+
"93", # AT LOCAL DEPOT LMC
|
|
223
|
+
"2101", # IN TRANSIT - EXPORTED
|
|
224
|
+
"2102", # ITEM RELEASED FROM CUSTOMS
|
|
225
|
+
"2103", # IN TRANSIT - IMPORTED
|
|
226
|
+
"9101", # AT TRANSFER DEPOT LMC
|
|
227
|
+
"9102", # IN TRANSIT
|
|
228
|
+
"9999", # INFORMATION
|
|
229
|
+
]
|
|
230
|
+
delivery_failed = [
|
|
231
|
+
"91", # DELIVERY ATTEMPTED
|
|
232
|
+
"111", # LOST OR DESTROYED
|
|
233
|
+
"1001", # ITEM INCOMPLETE DATA
|
|
234
|
+
"4106", # CONSIGNMENT CANCELLED
|
|
235
|
+
]
|
|
236
|
+
delivery_delayed = [
|
|
237
|
+
"9302", # DELIVERY EXCEPTION - DELAYED
|
|
238
|
+
]
|
|
239
|
+
out_for_delivery = [
|
|
240
|
+
"9301", # OUT FOR DELIVERY
|
|
241
|
+
]
|
|
242
|
+
ready_for_pickup = [
|
|
243
|
+
"92", # DELIVERY AWAITING COLLECTION
|
|
244
|
+
]
|
|
245
|
+
return_to_sender = [
|
|
246
|
+
"124", # RETURN IN TRANSIT
|
|
247
|
+
"125", # RETURN RECEIVED
|
|
248
|
+
"12406", # RETURN DELIVERED BY CARRIER
|
|
249
|
+
"12501", # RETURN RECEIVED - REFUSED
|
|
250
|
+
"12502", # RETURN RECEIVED - UNDELIVERABLE
|
|
251
|
+
"12503", # RETURN RECEIVED - DAMAGED
|
|
252
|
+
"12504", # RETURN RECEIVED - NOT COLLECTED
|
|
253
|
+
"12505", # RETURN RECEIVED - ACCORDING TO AGREEMENT
|
|
254
|
+
"12506", # RETURN RECEIVED - DESTROYED
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TrackingIncidentReason(lib.Enum):
|
|
259
|
+
"""Maps Spring exception codes to normalized incident reasons.
|
|
260
|
+
|
|
261
|
+
Maps carrier-specific exception/status codes to standardized
|
|
262
|
+
incident reasons for tracking events. The reason field helps
|
|
263
|
+
identify why a delivery exception occurred.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
# Carrier-caused issues
|
|
267
|
+
carrier_parcel_lost = ["111"] # LOST OR DESTROYED
|
|
268
|
+
carrier_damaged_parcel = ["12503"] # RETURN RECEIVED - DAMAGED
|
|
269
|
+
|
|
270
|
+
# Consignee-caused issues
|
|
271
|
+
consignee_refused = ["12501"] # RETURN RECEIVED - REFUSED
|
|
272
|
+
consignee_not_available = [
|
|
273
|
+
"91", # DELIVERY ATTEMPTED
|
|
274
|
+
"12504", # RETURN RECEIVED - NOT COLLECTED
|
|
275
|
+
]
|
|
276
|
+
consignee_incorrect_address = ["12502"] # RETURN RECEIVED - UNDELIVERABLE
|
|
277
|
+
|
|
278
|
+
# Customs-related issues
|
|
279
|
+
customs_delay = [
|
|
280
|
+
"40", # IN CUSTOMS
|
|
281
|
+
"41", # CUSTOMS EXCEPTION
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
# Delivery exceptions
|
|
285
|
+
delivery_exception_delayed = ["9302"] # DELIVERY EXCEPTION - DELAYED
|
|
286
|
+
delivery_exception_action_required = ["31"] # DELIVERY EXCEPTION – ACTION REQUIRED
|
|
287
|
+
delivery_exception_cancelled = ["4106"] # CONSIGNMENT CANCELLED
|
|
288
|
+
delivery_exception_incomplete_data = ["1001"] # ITEM INCOMPLETE DATA
|
|
289
|
+
|
|
290
|
+
# Return reasons
|
|
291
|
+
return_by_agreement = ["12505"] # RETURN RECEIVED - ACCORDING TO AGREEMENT
|
|
292
|
+
return_destroyed = ["12506"] # RETURN RECEIVED - DESTROYED
|
|
293
|
+
|
|
294
|
+
# Unknown
|
|
295
|
+
unknown = []
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def load_services_from_csv() -> list:
|
|
299
|
+
"""
|
|
300
|
+
Load service definitions from CSV file.
|
|
301
|
+
CSV format: service_code,service_name,zone_label,country_codes,min_weight,max_weight,max_length,max_width,max_height,rate,currency,transit_days,domicile,international
|
|
302
|
+
"""
|
|
303
|
+
csv_path = pathlib.Path(__file__).resolve().parent / "services.csv"
|
|
304
|
+
|
|
305
|
+
if not csv_path.exists():
|
|
306
|
+
# Fallback to simple default if CSV doesn't exist
|
|
307
|
+
return [
|
|
308
|
+
models.ServiceLevel(
|
|
309
|
+
service_name="Spring Tracked",
|
|
310
|
+
service_code="spring_tracked",
|
|
311
|
+
currency="EUR",
|
|
312
|
+
international=True,
|
|
313
|
+
zones=[models.ServiceZone(rate=0.0)],
|
|
314
|
+
)
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
# Group zones by service
|
|
318
|
+
services_dict: dict[str, dict] = {}
|
|
319
|
+
|
|
320
|
+
with open(csv_path, "r", encoding="utf-8") as f:
|
|
321
|
+
reader = csv.DictReader(f)
|
|
322
|
+
for row in reader:
|
|
323
|
+
service_code = row["service_code"]
|
|
324
|
+
service_name = row["service_name"]
|
|
325
|
+
|
|
326
|
+
# Map carrier service code to karrio service code
|
|
327
|
+
karrio_service_code = ShippingService.map(service_code).name_or_key
|
|
328
|
+
|
|
329
|
+
# Initialize service if not exists
|
|
330
|
+
if karrio_service_code not in services_dict:
|
|
331
|
+
services_dict[karrio_service_code] = {
|
|
332
|
+
"service_name": service_name,
|
|
333
|
+
"service_code": karrio_service_code,
|
|
334
|
+
"currency": row.get("currency", "EUR"),
|
|
335
|
+
"min_weight": (
|
|
336
|
+
float(row["min_weight"]) if row.get("min_weight") else None
|
|
337
|
+
),
|
|
338
|
+
"max_weight": (
|
|
339
|
+
float(row["max_weight"]) if row.get("max_weight") else None
|
|
340
|
+
),
|
|
341
|
+
"max_length": (
|
|
342
|
+
float(row["max_length"]) if row.get("max_length") else None
|
|
343
|
+
),
|
|
344
|
+
"max_width": (
|
|
345
|
+
float(row["max_width"]) if row.get("max_width") else None
|
|
346
|
+
),
|
|
347
|
+
"max_height": (
|
|
348
|
+
float(row["max_height"]) if row.get("max_height") else None
|
|
349
|
+
),
|
|
350
|
+
"weight_unit": "KG",
|
|
351
|
+
"dimension_unit": "CM",
|
|
352
|
+
"domicile": (row.get("domicile") or "").lower() == "true",
|
|
353
|
+
"international": (
|
|
354
|
+
True if (row.get("international") or "").lower() == "true" else None
|
|
355
|
+
),
|
|
356
|
+
"zones": [],
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# Parse country codes
|
|
360
|
+
country_codes = [
|
|
361
|
+
c.strip() for c in row.get("country_codes", "").split(",") if c.strip()
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
# Create zone
|
|
365
|
+
zone = models.ServiceZone(
|
|
366
|
+
label=row.get("zone_label", "Default Zone"),
|
|
367
|
+
rate=float(row.get("rate", 0.0)),
|
|
368
|
+
transit_days=(
|
|
369
|
+
int(row["transit_days"]) if row.get("transit_days") else None
|
|
370
|
+
),
|
|
371
|
+
country_codes=country_codes if country_codes else None,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
services_dict[karrio_service_code]["zones"].append(zone)
|
|
375
|
+
|
|
376
|
+
# Convert to ServiceLevel objects
|
|
377
|
+
return [
|
|
378
|
+
models.ServiceLevel(**service_data) for service_data in services_dict.values()
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
DEFAULT_SERVICES = load_services_from_csv()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
|
|
2
|
+
import karrio.lib as lib
|
|
3
|
+
import karrio.core as core
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Settings(core.Settings):
|
|
7
|
+
"""Spring connection settings."""
|
|
8
|
+
|
|
9
|
+
api_key: str
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def carrier_name(self):
|
|
13
|
+
return "spring"
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def server_url(self):
|
|
17
|
+
return (
|
|
18
|
+
"https://mtapi.net/?testMode=1"
|
|
19
|
+
if self.test_mode
|
|
20
|
+
else "https://mtapi.net/"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def tracking_url(self):
|
|
25
|
+
return "https://www.mailingtechnology.com/tracking/?tn={}"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def connection_config(self) -> lib.units.Options:
|
|
29
|
+
from karrio.providers.spring.units import ConnectionConfig
|
|
30
|
+
|
|
31
|
+
return lib.to_connection_config(
|
|
32
|
+
self.config or {},
|
|
33
|
+
option_type=ConnectionConfig,
|
|
34
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import attr
|
|
2
|
+
import jstruct
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@attr.s(auto_attribs=True)
|
|
7
|
+
class ShipmentType:
|
|
8
|
+
TrackingNumber: typing.Optional[str] = None
|
|
9
|
+
ShipperReference: typing.Optional[str] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@attr.s(auto_attribs=True)
|
|
13
|
+
class ShipmentCancelRequestType:
|
|
14
|
+
Apikey: typing.Optional[str] = None
|
|
15
|
+
Command: typing.Optional[str] = None
|
|
16
|
+
Shipment: typing.Optional[ShipmentType] = jstruct.JStruct[ShipmentType]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import attr
|
|
2
|
+
import jstruct
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@attr.s(auto_attribs=True)
|
|
7
|
+
class ShipmentType:
|
|
8
|
+
TrackingNumber: typing.Optional[str] = None
|
|
9
|
+
ShipperReference: typing.Optional[str] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@attr.s(auto_attribs=True)
|
|
13
|
+
class ShipmentCancelResponseType:
|
|
14
|
+
ErrorLevel: typing.Optional[int] = None
|
|
15
|
+
Error: typing.Optional[str] = None
|
|
16
|
+
Shipment: typing.Optional[ShipmentType] = jstruct.JStruct[ShipmentType]
|