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.
- karrio/mappers/postat/__init__.py +3 -0
- karrio/mappers/postat/mapper.py +41 -0
- karrio/mappers/postat/proxy.py +43 -0
- karrio/mappers/postat/settings.py +35 -0
- karrio/plugins/postat/__init__.py +28 -0
- karrio/providers/postat/__init__.py +8 -0
- karrio/providers/postat/error.py +30 -0
- karrio/providers/postat/shipment/__init__.py +9 -0
- karrio/providers/postat/shipment/cancel.py +65 -0
- karrio/providers/postat/shipment/create.py +147 -0
- karrio/providers/postat/units.py +241 -0
- karrio/providers/postat/utils.py +54 -0
- karrio/schemas/postat/__init__.py +0 -0
- karrio/schemas/postat/plc_types.py +3344 -0
- karrio/schemas/postat/void_types.py +1734 -0
- karrio_postat-2026.1.dist-info/METADATA +44 -0
- karrio_postat-2026.1.dist-info/RECORD +20 -0
- karrio_postat-2026.1.dist-info/WHEEL +5 -0
- karrio_postat-2026.1.dist-info/entry_points.txt +2 -0
- karrio_postat-2026.1.dist-info/top_level.txt +4 -0
|
@@ -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,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,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()
|