karrio-postat 2026.1__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.
@@ -0,0 +1,3 @@
1
+ from karrio.mappers.postat.mapper import Mapper
2
+ from karrio.mappers.postat.proxy import Proxy
3
+ from karrio.mappers.postat.settings import Settings
@@ -0,0 +1,41 @@
1
+ """Karrio PostAT 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.postat as provider
8
+ import karrio.mappers.postat.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_shipment_request(
19
+ self, payload: models.ShipmentRequest
20
+ ) -> lib.Serializable:
21
+ return provider.shipment_request(payload, self.settings)
22
+
23
+ def create_cancel_shipment_request(
24
+ self, payload: models.ShipmentCancelRequest
25
+ ) -> lib.Serializable[str]:
26
+ return provider.shipment_cancel_request(payload, self.settings)
27
+
28
+ def parse_rate_response(
29
+ self, response: lib.Deserializable[str]
30
+ ) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]:
31
+ return universal_provider.parse_rate_response(response, self.settings)
32
+
33
+ def parse_cancel_shipment_response(
34
+ self, response: lib.Deserializable[str]
35
+ ) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]:
36
+ return provider.parse_shipment_cancel_response(response, self.settings)
37
+
38
+ def parse_shipment_response(
39
+ self, response: lib.Deserializable[str]
40
+ ) -> typing.Tuple[models.ShipmentDetails, typing.List[models.Message]]:
41
+ return provider.parse_shipment_response(response, self.settings)
@@ -0,0 +1,43 @@
1
+ """Karrio PostAT client proxy."""
2
+
3
+ import karrio.lib as lib
4
+ import karrio.api.proxy as proxy
5
+ import karrio.mappers.postat.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 a shipment using ImportShipment SOAP operation."""
17
+ response = lib.request(
18
+ url=self.settings.server_url,
19
+ data=request.serialize(),
20
+ trace=self.trace_as("xml"),
21
+ method="POST",
22
+ headers={
23
+ "Content-Type": "text/xml; charset=utf-8",
24
+ "SOAPAction": "http://post.ondot.at/IShippingService/ImportShipment",
25
+ },
26
+ )
27
+
28
+ return lib.Deserializable(response, lib.to_element)
29
+
30
+ def cancel_shipment(self, request: lib.Serializable) -> lib.Deserializable[str]:
31
+ """Cancel a shipment using CancelShipments SOAP operation."""
32
+ response = lib.request(
33
+ url=self.settings.server_url,
34
+ data=request.serialize(),
35
+ trace=self.trace_as("xml"),
36
+ method="POST",
37
+ headers={
38
+ "Content-Type": "text/xml; charset=utf-8",
39
+ "SOAPAction": "http://post.ondot.at/IShippingService/CancelShipments",
40
+ },
41
+ )
42
+
43
+ return lib.Deserializable(response, lib.to_element)
@@ -0,0 +1,35 @@
1
+ """Karrio PostAT client settings."""
2
+
3
+ import attr
4
+ import typing
5
+ import jstruct
6
+ import karrio.core.models as models
7
+ import karrio.providers.postat.units as provider_units
8
+ import karrio.providers.postat.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
+ """PostAT connection settings."""
15
+
16
+ # Required credentials (from Austrian Post onboarding)
17
+ client_id: str
18
+ org_unit_id: str
19
+ org_unit_guid: str
20
+
21
+ # generic properties
22
+ id: str = None
23
+ test_mode: bool = False
24
+ carrier_id: str = "postat"
25
+ services: typing.List[models.ServiceLevel] = jstruct.JList[models.ServiceLevel, False, dict(default=provider_units.DEFAULT_SERVICES)] # type: ignore
26
+ account_country_code: str = "AT"
27
+ metadata: dict = {}
28
+ config: dict = {}
29
+
30
+ @property
31
+ def shipping_services(self) -> typing.List[models.ServiceLevel]:
32
+ if any(self.services or []):
33
+ return self.services
34
+
35
+ return provider_units.DEFAULT_SERVICES
@@ -0,0 +1,28 @@
1
+ from karrio.core.metadata import PluginMetadata
2
+
3
+ from karrio.mappers.postat.mapper import Mapper
4
+ from karrio.mappers.postat.proxy import Proxy
5
+ from karrio.mappers.postat.settings import Settings
6
+ import karrio.providers.postat.units as units
7
+
8
+
9
+ # This METADATA object is used by Karrio to discover and register this plugin
10
+ # when loaded through Python entrypoints or local plugin directories.
11
+ # The entrypoint is defined in pyproject.toml under [project.entry-points."karrio.plugins"]
12
+ METADATA = PluginMetadata(
13
+ id="postat",
14
+ label="PostAT",
15
+ description="Austrian Post (Österreichische Post) shipping integration via Post-Labelcenter API",
16
+ # Integrations
17
+ Mapper=Mapper,
18
+ Proxy=Proxy,
19
+ Settings=Settings,
20
+ # Data Units
21
+ is_hub=False,
22
+ options=units.ShippingOption,
23
+ services=units.ShippingService,
24
+ connection_configs=units.ConnectionConfig,
25
+ # Extra info
26
+ website="https://www.post.at",
27
+ documentation="https://www.post.at/en/business-post-labelcenter",
28
+ )
@@ -0,0 +1,8 @@
1
+ """Karrio PostAT provider imports."""
2
+ from karrio.providers.postat.utils import Settings
3
+ from karrio.providers.postat.shipment import (
4
+ parse_shipment_cancel_response,
5
+ parse_shipment_response,
6
+ shipment_cancel_request,
7
+ shipment_request,
8
+ )
@@ -0,0 +1,30 @@
1
+ """Karrio PostAT error parser."""
2
+
3
+ import typing
4
+ import karrio.lib as lib
5
+ import karrio.core.models as models
6
+ from karrio.core.utils.soap import extract_fault
7
+ import karrio.providers.postat.utils as provider_utils
8
+
9
+
10
+ def parse_error_response(
11
+ response: lib.Element,
12
+ settings: provider_utils.Settings,
13
+ **kwargs,
14
+ ) -> typing.List[models.Message]:
15
+ """Parse error response from PostAT SOAP API."""
16
+ errors = lib.find_element("Error", response) or []
17
+
18
+ return [
19
+ models.Message(
20
+ carrier_id=settings.carrier_id,
21
+ carrier_name=settings.carrier_name,
22
+ code=error.findtext("Code") or "ERROR",
23
+ message=error.findtext("Message") or "",
24
+ details={
25
+ **({"description": error.findtext("Description")} if error.findtext("Description") else {}),
26
+ **kwargs,
27
+ },
28
+ )
29
+ for error in errors
30
+ ] + extract_fault(response, settings)
@@ -0,0 +1,9 @@
1
+
2
+ from karrio.providers.postat.shipment.create import (
3
+ parse_shipment_response,
4
+ shipment_request,
5
+ )
6
+ from karrio.providers.postat.shipment.cancel import (
7
+ parse_shipment_cancel_response,
8
+ shipment_cancel_request,
9
+ )
@@ -0,0 +1,65 @@
1
+ """Karrio PostAT shipment cancellation API implementation."""
2
+
3
+ import karrio.schemas.postat.void_types as postat_void
4
+
5
+ import typing
6
+ import karrio.lib as lib
7
+ import karrio.core.models as models
8
+ import karrio.providers.postat.error as error
9
+ import karrio.providers.postat.utils as provider_utils
10
+
11
+
12
+ def parse_shipment_cancel_response(
13
+ _response: lib.Deserializable[lib.Element],
14
+ settings: provider_utils.Settings,
15
+ ) -> typing.Tuple[typing.Optional[models.ConfirmationDetails], typing.List[models.Message]]:
16
+ """Parse shipment cancellation response from PostAT SOAP API."""
17
+ response = _response.deserialize()
18
+ messages = error.parse_error_response(response, settings)
19
+
20
+ # Check for VoidShipmentResult
21
+ result = lib.find_element("VoidShipmentResult", response, first=True)
22
+ success_elem = lib.find_element("Success", result, first=True)
23
+ success = (
24
+ success_elem is not None
25
+ and success_elem.text
26
+ and success_elem.text.lower() == "true"
27
+ and not any(messages)
28
+ )
29
+
30
+ confirmation = (
31
+ models.ConfirmationDetails(
32
+ carrier_id=settings.carrier_id,
33
+ carrier_name=settings.carrier_name,
34
+ operation="Cancel Shipment",
35
+ success=success,
36
+ )
37
+ if success
38
+ else None
39
+ )
40
+
41
+ return confirmation, messages
42
+
43
+
44
+ def shipment_cancel_request(
45
+ payload: models.ShipmentCancelRequest,
46
+ settings: provider_utils.Settings,
47
+ ) -> lib.Serializable:
48
+ """Create a shipment cancellation request for the PostAT SOAP API."""
49
+ # Build request using generated schema types
50
+ request = lib.Envelope(
51
+ Body=lib.Body(
52
+ postat_void.VoidShipmentType(
53
+ row=[
54
+ postat_void.VoidShipmentRowType(
55
+ TrackingNumber=payload.shipment_identifier,
56
+ OrgUnitID=settings.org_unit_id,
57
+ OrgUnitGuid=settings.org_unit_guid,
58
+ )
59
+ ]
60
+ )
61
+ )
62
+ )
63
+
64
+ return lib.Serializable(request, lib.envelope_serializer)
65
+
@@ -0,0 +1,147 @@
1
+ """Karrio PostAT shipment API implementation."""
2
+
3
+ import karrio.schemas.postat.plc_types as postat
4
+
5
+ import typing
6
+ import karrio.lib as lib
7
+ import karrio.core.models as models
8
+ import karrio.providers.postat.error as error
9
+ import karrio.providers.postat.utils as provider_utils
10
+ import karrio.providers.postat.units as provider_units
11
+
12
+
13
+ def parse_shipment_response(
14
+ _response: lib.Deserializable[lib.Element],
15
+ settings: provider_utils.Settings,
16
+ ) -> typing.Tuple[typing.Optional[models.ShipmentDetails], typing.List[models.Message]]:
17
+ response = _response.deserialize()
18
+ messages = error.parse_error_response(response, settings)
19
+ result = lib.find_element("ImportShipmentResult", response, first=True)
20
+ shipment = (
21
+ _extract_details(response, settings)
22
+ if result is not None and not any(messages)
23
+ else None
24
+ )
25
+
26
+ return shipment, messages
27
+
28
+
29
+ def _extract_details(
30
+ response: lib.Element,
31
+ settings: provider_utils.Settings,
32
+ ) -> models.ShipmentDetails:
33
+ """Extract shipment details from ImportShipmentResponse."""
34
+ result = lib.find_element("ImportShipmentResult", response, first=True)
35
+ pdf_data = lib.find_element("pdfData", response, first=True)
36
+ zpl_data = lib.find_element("zplLabelData", response, first=True)
37
+
38
+ code_elements = lib.find_element("Code", result) or []
39
+ tracking_numbers = [code.text for code in code_elements if code.text]
40
+ tracking_number = tracking_numbers[0]
41
+
42
+ label = lib.failsafe(lambda: zpl_data.text) or lib.failsafe(lambda: pdf_data.text)
43
+ label_type = "ZPL" if zpl_data is not None and zpl_data.text else "PDF"
44
+
45
+ return models.ShipmentDetails(
46
+ carrier_id=settings.carrier_id,
47
+ carrier_name=settings.carrier_name,
48
+ tracking_number=tracking_number,
49
+ shipment_identifier=tracking_number,
50
+ label_type=label_type,
51
+ docs=models.Documents(label=label),
52
+ meta=dict(
53
+ tracking_numbers=tracking_numbers,
54
+ ),
55
+ )
56
+
57
+
58
+ def shipment_request(
59
+ payload: models.ShipmentRequest,
60
+ settings: provider_utils.Settings,
61
+ ) -> lib.Serializable:
62
+ """Create a shipment request for the PostAT SOAP API."""
63
+ shipper = lib.to_address(payload.shipper)
64
+ recipient = lib.to_address(payload.recipient)
65
+ packages = lib.to_packages(payload.parcels)
66
+ service = provider_units.ShippingService.map(payload.service).value_or_key
67
+ options = lib.to_shipping_options(
68
+ payload.options,
69
+ package_options=packages.options,
70
+ initializer=provider_units.shipping_options_initializer,
71
+ )
72
+
73
+ # Label configuration with fallbacks
74
+ label_format = lib.identity(
75
+ settings.connection_config.label_format.state or payload.label_type
76
+ )
77
+ label_size = lib.identity(
78
+ settings.connection_config.label_size.state
79
+ or options.postat_label_size.state
80
+ or "SIZE_100x150"
81
+ )
82
+ paper_layout = lib.identity(
83
+ settings.connection_config.paper_layout.state
84
+ or options.postat_paper_layout.state
85
+ or "LAYOUT_2xA5inA4"
86
+ )
87
+
88
+ # Build request using generated schema types
89
+ request = lib.Envelope(
90
+ Body=lib.Body(
91
+ postat.ImportShipmentType(
92
+ row=[
93
+ postat.ImportShipmentRowType(
94
+ ClientID=settings.client_id,
95
+ OrgUnitID=settings.org_unit_id,
96
+ OrgUnitGuid=settings.org_unit_guid,
97
+ DeliveryServiceThirdPartyID=service,
98
+ CustomDataBit1=False,
99
+ OUShipperReference1=payload.reference,
100
+ ColloList=postat.ColloListType(
101
+ ColloRow=[
102
+ postat.ColloRowType(
103
+ Weight=package.weight.KG,
104
+ Length=package.length.CM,
105
+ Width=package.width.CM,
106
+ Height=package.height.CM,
107
+ )
108
+ for package in packages
109
+ ]
110
+ ),
111
+ OURecipientAddress=postat.AddressType(
112
+ Name1=recipient.company_name or recipient.person_name,
113
+ Name2=(
114
+ recipient.person_name
115
+ if recipient.company_name
116
+ else None
117
+ ),
118
+ AddressLine1=recipient.street,
119
+ AddressLine2=recipient.address_line2,
120
+ HouseNumber=recipient.street_number,
121
+ PostalCode=recipient.postal_code,
122
+ City=recipient.city,
123
+ CountryID=recipient.country_code,
124
+ Email=recipient.email,
125
+ Tel1=recipient.phone_number,
126
+ ),
127
+ OUShipperAddress=postat.AddressType(
128
+ Name1=shipper.company_name or shipper.person_name,
129
+ Name2=shipper.person_name if shipper.company_name else None,
130
+ AddressLine1=shipper.street,
131
+ AddressLine2=shipper.address_line2,
132
+ PostalCode=shipper.postal_code,
133
+ City=shipper.city,
134
+ CountryID=shipper.country_code,
135
+ ),
136
+ PrinterObject=postat.PrinterObjectType(
137
+ LabelFormatID=label_size,
138
+ LanguageID=label_format,
139
+ PaperLayoutID=paper_layout,
140
+ ),
141
+ )
142
+ ]
143
+ )
144
+ )
145
+ )
146
+
147
+ return lib.Serializable(request, lib.envelope_serializer)
@@ -0,0 +1,241 @@
1
+ """PostAT units and enums."""
2
+
3
+ import csv
4
+ import pathlib
5
+ import karrio.lib as lib
6
+ import karrio.core.units as units
7
+ import karrio.core.models as models
8
+
9
+
10
+ class LabelFormat(lib.StrEnum):
11
+ """Supported label formats."""
12
+
13
+ PDF = "pdf"
14
+ ZPL2 = "zpl2"
15
+
16
+
17
+ class LabelSize(lib.StrEnum):
18
+ """Supported label sizes."""
19
+
20
+ SIZE_100x150 = "100x150"
21
+ SIZE_100x200 = "100x200"
22
+
23
+
24
+ class PaperLayout(lib.StrEnum):
25
+ """Supported paper layouts for PDF output."""
26
+
27
+ LAYOUT_2xA5inA4 = "2xA5inA4"
28
+ LAYOUT_4xA6inA4 = "4xA6inA4"
29
+ LAYOUT_A4 = "A4"
30
+
31
+
32
+ class ConnectionConfig(lib.Enum):
33
+ """PostAT connection configuration options."""
34
+
35
+ server_url = lib.OptionEnum("server_url", str)
36
+ label_format = lib.OptionEnum(
37
+ "label_format",
38
+ lib.units.create_enum("LabelFormat", [_.name for _ in LabelFormat]),
39
+ )
40
+ label_size = lib.OptionEnum(
41
+ "label_size",
42
+ lib.units.create_enum("LabelSize", [_.name for _ in LabelSize]),
43
+ )
44
+ paper_layout = lib.OptionEnum(
45
+ "paper_layout",
46
+ lib.units.create_enum("PaperLayout", [_.name for _ in PaperLayout]),
47
+ )
48
+ shipping_services = lib.OptionEnum("shipping_services", list)
49
+ shipping_options = lib.OptionEnum("shipping_options", list)
50
+
51
+
52
+ class ShippingService(lib.StrEnum):
53
+ """PostAT shipping services.
54
+
55
+ Note: Service codes (DeliveryServiceThirdPartyID) are configured per account
56
+ by Austrian Post. These are common examples.
57
+ """
58
+
59
+ postat_standard_domestic = "10"
60
+ postat_express_domestic = "20"
61
+ postat_international_standard = "30"
62
+ postat_international_express = "40"
63
+
64
+
65
+ class ShippingOption(lib.Enum):
66
+ """PostAT shipping options (Features)."""
67
+
68
+ # Label configuration (can be set per-shipment)
69
+ postat_label_size = lib.OptionEnum("label_size", str)
70
+ postat_paper_layout = lib.OptionEnum("paper_layout", str)
71
+
72
+ # Cash on Delivery
73
+ postat_cod = lib.OptionEnum("COD", float)
74
+ postat_cod_currency = lib.OptionEnum("COD_CURRENCY", str)
75
+
76
+ # Insurance
77
+ postat_insurance = lib.OptionEnum("INS", float)
78
+ postat_insurance_currency = lib.OptionEnum("INS_CURRENCY", str)
79
+
80
+ # Signature required
81
+ postat_signature = lib.OptionEnum("SIG", bool)
82
+
83
+ # Saturday delivery
84
+ postat_saturday_delivery = lib.OptionEnum("SAT", bool)
85
+
86
+ # Email notification
87
+ postat_email_notification = lib.OptionEnum("MAIL", str)
88
+
89
+ # SMS notification
90
+ postat_sms_notification = lib.OptionEnum("SMS", str)
91
+
92
+ # Age verification (16 or 18)
93
+ postat_age_verification = lib.OptionEnum("AGE", int)
94
+
95
+ # Unified option mappings
96
+ cash_on_delivery = postat_cod
97
+ insurance = postat_insurance
98
+ signature_required = postat_signature
99
+ saturday_delivery = postat_saturday_delivery
100
+ email_notification = postat_email_notification
101
+
102
+
103
+ def shipping_options_initializer(
104
+ options: dict,
105
+ package_options: units.ShippingOptions = None,
106
+ ) -> units.ShippingOptions:
107
+ """Apply default values to the given options."""
108
+ if package_options is not None:
109
+ options.update(package_options.content)
110
+
111
+ def items_filter(key: str) -> bool:
112
+ return key in ShippingOption # type: ignore
113
+
114
+ return units.ShippingOptions(options, ShippingOption, items_filter=items_filter)
115
+
116
+
117
+ class TrackingStatus(lib.Enum):
118
+ """PostAT tracking status mapping."""
119
+
120
+ pending = [
121
+ "REGISTERED",
122
+ "CREATED",
123
+ "DATA_RECEIVED",
124
+ "LABEL_PRINTED",
125
+ ]
126
+ delivered = [
127
+ "DELIVERED",
128
+ "ZUGESTELLT",
129
+ "POD",
130
+ ]
131
+ in_transit = [
132
+ "IN_TRANSIT",
133
+ "UNTERWEGS",
134
+ "DEPARTED",
135
+ "ARRIVED",
136
+ ]
137
+ out_for_delivery = [
138
+ "OUT_FOR_DELIVERY",
139
+ "IN_ZUSTELLUNG",
140
+ ]
141
+ on_hold = [
142
+ "HELD",
143
+ "CUSTOMS",
144
+ ]
145
+ delivery_failed = [
146
+ "FAILED",
147
+ "NOT_DELIVERED",
148
+ "REFUSED",
149
+ ]
150
+
151
+
152
+ def load_services_from_csv() -> list:
153
+ """
154
+ Load service definitions from CSV file.
155
+ 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
156
+ """
157
+ csv_path = pathlib.Path(__file__).resolve().parent / "services.csv"
158
+
159
+ if not csv_path.exists():
160
+ # Fallback to simple default if CSV doesn't exist
161
+ return [
162
+ models.ServiceLevel(
163
+ service_name="PostAT Standard Domestic",
164
+ service_code="postat_standard_domestic",
165
+ currency="EUR",
166
+ domicile=True,
167
+ zones=[models.ServiceZone(rate=0.0)],
168
+ )
169
+ ]
170
+
171
+ # Group zones by service
172
+ services_dict: dict[str, dict] = {}
173
+
174
+ with open(csv_path, "r", encoding="utf-8") as f:
175
+ reader = csv.DictReader(f)
176
+ for row in reader:
177
+ service_code = row["service_code"]
178
+ service_name = row["service_name"]
179
+
180
+ # Map carrier service code to karrio service code
181
+ karrio_service_code = ShippingService.map(service_code).name_or_key
182
+
183
+ # Initialize service if not exists
184
+ if karrio_service_code not in services_dict:
185
+ services_dict[karrio_service_code] = {
186
+ "service_name": service_name,
187
+ "service_code": karrio_service_code,
188
+ "currency": row.get("currency", "EUR"),
189
+ "min_weight": (
190
+ float(row["min_weight"]) if row.get("min_weight") else None
191
+ ),
192
+ "max_weight": (
193
+ float(row["max_weight"]) if row.get("max_weight") else None
194
+ ),
195
+ "max_length": (
196
+ float(row["max_length"]) if row.get("max_length") else None
197
+ ),
198
+ "max_width": (
199
+ float(row["max_width"]) if row.get("max_width") else None
200
+ ),
201
+ "max_height": (
202
+ float(row["max_height"]) if row.get("max_height") else None
203
+ ),
204
+ "weight_unit": "KG",
205
+ "dimension_unit": "CM",
206
+ "domicile": row.get("domicile", "").lower() == "true",
207
+ "international": (
208
+ True if row.get("international", "").lower() == "true" else None
209
+ ),
210
+ "zones": [],
211
+ }
212
+
213
+ # Parse country codes
214
+ country_codes = [
215
+ c.strip() for c in row.get("country_codes", "").split(",") if c.strip()
216
+ ]
217
+
218
+ # Parse transit days (handle "1-3" format)
219
+ transit_days = None
220
+ if row.get("transit_days"):
221
+ transit_str = row["transit_days"].split("-")[0]
222
+ if transit_str.isdigit():
223
+ transit_days = int(transit_str)
224
+
225
+ # Create zone
226
+ zone = models.ServiceZone(
227
+ label=row.get("zone_label", "Default Zone"),
228
+ rate=float(row.get("rate", 0.0)),
229
+ transit_days=transit_days,
230
+ country_codes=country_codes if country_codes else None,
231
+ )
232
+
233
+ services_dict[karrio_service_code]["zones"].append(zone)
234
+
235
+ # Convert to ServiceLevel objects
236
+ return [
237
+ models.ServiceLevel(**service_data) for service_data in services_dict.values()
238
+ ]
239
+
240
+
241
+ DEFAULT_SERVICES = load_services_from_csv()