karrio-hermes 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/hermes/__init__.py +3 -0
- karrio/mappers/hermes/mapper.py +59 -0
- karrio/mappers/hermes/proxy.py +78 -0
- karrio/mappers/hermes/settings.py +36 -0
- karrio/plugins/hermes/__init__.py +29 -0
- karrio/providers/hermes/__init__.py +16 -0
- karrio/providers/hermes/error.py +94 -0
- karrio/providers/hermes/pickup/__init__.py +12 -0
- karrio/providers/hermes/pickup/cancel.py +49 -0
- karrio/providers/hermes/pickup/create.py +98 -0
- karrio/providers/hermes/shipment/__init__.py +8 -0
- karrio/providers/hermes/shipment/create.py +336 -0
- karrio/providers/hermes/units.py +242 -0
- karrio/providers/hermes/utils.py +102 -0
- karrio/schemas/hermes/__init__.py +43 -0
- karrio/schemas/hermes/error_response.py +14 -0
- karrio/schemas/hermes/pickup_cancel_request.py +8 -0
- karrio/schemas/hermes/pickup_cancel_response.py +15 -0
- karrio/schemas/hermes/pickup_create_request.py +41 -0
- karrio/schemas/hermes/pickup_create_response.py +15 -0
- karrio/schemas/hermes/shipment_request.py +195 -0
- karrio/schemas/hermes/shipment_response.py +97 -0
- karrio_hermes-2026.1.dist-info/METADATA +44 -0
- karrio_hermes-2026.1.dist-info/RECORD +27 -0
- karrio_hermes-2026.1.dist-info/WHEEL +5 -0
- karrio_hermes-2026.1.dist-info/entry_points.txt +2 -0
- karrio_hermes-2026.1.dist-info/top_level.txt +4 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Karrio Hermes shipment API implementation."""
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
import karrio.lib as lib
|
|
5
|
+
import karrio.core.units as units
|
|
6
|
+
import karrio.core.models as models
|
|
7
|
+
import karrio.providers.hermes.error as error
|
|
8
|
+
import karrio.providers.hermes.utils as provider_utils
|
|
9
|
+
import karrio.providers.hermes.units as provider_units
|
|
10
|
+
import karrio.schemas.hermes.shipment_request as hermes_req
|
|
11
|
+
import karrio.schemas.hermes.shipment_response as hermes_res
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _split_name(name: typing.Optional[str]) -> typing.Tuple[str, str]:
|
|
15
|
+
"""Split full name into firstname and lastname for Hermes API."""
|
|
16
|
+
if not name:
|
|
17
|
+
return (None, None)
|
|
18
|
+
parts = name.split()
|
|
19
|
+
firstname = parts[0] if parts else None
|
|
20
|
+
lastname = " ".join(parts[1:]) if len(parts) > 1 else firstname
|
|
21
|
+
return (firstname, lastname)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_shipment_response(
|
|
25
|
+
_response: lib.Deserializable[dict],
|
|
26
|
+
settings: provider_utils.Settings,
|
|
27
|
+
) -> typing.Tuple[typing.Optional[models.ShipmentDetails], typing.List[models.Message]]:
|
|
28
|
+
"""Parse Hermes shipment response."""
|
|
29
|
+
response = _response.deserialize()
|
|
30
|
+
messages = error.parse_error_response(response, settings)
|
|
31
|
+
|
|
32
|
+
# Check if we have valid shipment data (shipmentID indicates success)
|
|
33
|
+
# Only proceed if response is a dict
|
|
34
|
+
shipment = None
|
|
35
|
+
if isinstance(response, dict) and (response.get("shipmentID") or response.get("shipmentOrderID")):
|
|
36
|
+
shipment = _extract_details(response, settings)
|
|
37
|
+
|
|
38
|
+
return shipment, messages
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extract_details(
|
|
42
|
+
data: dict,
|
|
43
|
+
settings: provider_utils.Settings,
|
|
44
|
+
) -> models.ShipmentDetails:
|
|
45
|
+
"""Extract shipment details from Hermes response."""
|
|
46
|
+
response = lib.to_object(hermes_res.ShipmentResponseType, data)
|
|
47
|
+
|
|
48
|
+
# Hermes uses shipmentID as tracking number (14 or 20 characters)
|
|
49
|
+
tracking_number = response.shipmentID or ""
|
|
50
|
+
shipment_order_id = response.shipmentOrderID or ""
|
|
51
|
+
|
|
52
|
+
# Label is returned as base64 encoded image
|
|
53
|
+
label_image = response.labelImage or ""
|
|
54
|
+
|
|
55
|
+
# Commercial invoice for international shipments
|
|
56
|
+
invoice_image = response.commInvoiceImage or ""
|
|
57
|
+
|
|
58
|
+
# Label media type
|
|
59
|
+
label_type = response.labelMediatype or "PDF"
|
|
60
|
+
|
|
61
|
+
documents = models.Documents(label=label_image)
|
|
62
|
+
if invoice_image:
|
|
63
|
+
documents.invoice = invoice_image
|
|
64
|
+
|
|
65
|
+
return models.ShipmentDetails(
|
|
66
|
+
carrier_id=settings.carrier_id,
|
|
67
|
+
carrier_name=settings.carrier_name,
|
|
68
|
+
tracking_number=tracking_number,
|
|
69
|
+
shipment_identifier=shipment_order_id,
|
|
70
|
+
label_type=label_type,
|
|
71
|
+
docs=documents,
|
|
72
|
+
meta=dict(
|
|
73
|
+
shipment_id=tracking_number,
|
|
74
|
+
shipment_order_id=shipment_order_id,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def shipment_request(
|
|
80
|
+
payload: models.ShipmentRequest,
|
|
81
|
+
settings: provider_utils.Settings,
|
|
82
|
+
) -> lib.Serializable:
|
|
83
|
+
"""Create a Hermes shipment request."""
|
|
84
|
+
shipper = lib.to_address(payload.shipper)
|
|
85
|
+
recipient = lib.to_address(payload.recipient)
|
|
86
|
+
packages = lib.to_packages(payload.parcels)
|
|
87
|
+
package = packages.single # Hermes handles one parcel per request
|
|
88
|
+
options = lib.to_shipping_options(
|
|
89
|
+
payload.options,
|
|
90
|
+
package_options=packages.options,
|
|
91
|
+
initializer=provider_units.shipping_options_initializer,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Determine product type
|
|
95
|
+
product_type = provider_units.PackagingType.map(
|
|
96
|
+
package.packaging_type or "your_packaging"
|
|
97
|
+
).value
|
|
98
|
+
|
|
99
|
+
# Build services object based on options
|
|
100
|
+
service = _build_service(options)
|
|
101
|
+
|
|
102
|
+
# Build customs for international shipments
|
|
103
|
+
customs = None
|
|
104
|
+
if payload.customs:
|
|
105
|
+
customs = _build_customs(payload.customs, shipper)
|
|
106
|
+
|
|
107
|
+
# Split names for Hermes API
|
|
108
|
+
recipient_firstname, recipient_lastname = _split_name(recipient.person_name)
|
|
109
|
+
shipper_firstname, shipper_lastname = _split_name(shipper.person_name)
|
|
110
|
+
|
|
111
|
+
# Create the request using generated schema types
|
|
112
|
+
# Field length limits per OpenAPI spec:
|
|
113
|
+
# - street: 50, houseNumber: 5, town: 30
|
|
114
|
+
# - addressAddition: 50, addressAddition2: 20, addressAddition3: 20
|
|
115
|
+
# - clientReference: 20, clientReference2: 20, phone: 20
|
|
116
|
+
request = hermes_req.ShipmentRequestType(
|
|
117
|
+
clientReference=lib.text(payload.reference, max=20) or "",
|
|
118
|
+
clientReference2=lib.text((payload.options or {}).get("clientReference2"), max=20),
|
|
119
|
+
# Receiver name
|
|
120
|
+
receiverName=hermes_req.ErNameType(
|
|
121
|
+
title=None,
|
|
122
|
+
gender=None,
|
|
123
|
+
firstname=recipient_firstname,
|
|
124
|
+
middlename=None,
|
|
125
|
+
lastname=recipient_lastname,
|
|
126
|
+
),
|
|
127
|
+
# Receiver address
|
|
128
|
+
receiverAddress=hermes_req.ErAddressType(
|
|
129
|
+
street=lib.text(recipient.street_name, max=50),
|
|
130
|
+
houseNumber=lib.text(recipient.street_number, max=5) or "",
|
|
131
|
+
zipCode=recipient.postal_code,
|
|
132
|
+
town=lib.text(recipient.city, max=30),
|
|
133
|
+
countryCode=recipient.country_code,
|
|
134
|
+
addressAddition=lib.text(recipient.address_line2, max=50),
|
|
135
|
+
addressAddition2=None,
|
|
136
|
+
addressAddition3=lib.text(recipient.company_name, max=20),
|
|
137
|
+
),
|
|
138
|
+
# Receiver contact
|
|
139
|
+
receiverContact=lib.identity(
|
|
140
|
+
hermes_req.ReceiverContactType(
|
|
141
|
+
phone=lib.text(recipient.phone_number, max=20),
|
|
142
|
+
mobile=None,
|
|
143
|
+
mail=recipient.email,
|
|
144
|
+
)
|
|
145
|
+
if recipient.phone_number or recipient.email
|
|
146
|
+
else None
|
|
147
|
+
),
|
|
148
|
+
# Sender (divergent sender if different from account default)
|
|
149
|
+
senderName=lib.identity(
|
|
150
|
+
hermes_req.ErNameType(
|
|
151
|
+
title=None,
|
|
152
|
+
gender=None,
|
|
153
|
+
firstname=shipper_firstname,
|
|
154
|
+
middlename=None,
|
|
155
|
+
lastname=shipper_lastname,
|
|
156
|
+
)
|
|
157
|
+
if shipper.person_name
|
|
158
|
+
else None
|
|
159
|
+
),
|
|
160
|
+
senderAddress=lib.identity(
|
|
161
|
+
hermes_req.ErAddressType(
|
|
162
|
+
street=lib.text(shipper.street_name, max=50),
|
|
163
|
+
houseNumber=lib.text(shipper.street_number, max=5) or "",
|
|
164
|
+
zipCode=shipper.postal_code,
|
|
165
|
+
town=lib.text(shipper.city, max=30),
|
|
166
|
+
countryCode=shipper.country_code,
|
|
167
|
+
addressAddition=lib.text(shipper.address_line2, max=50),
|
|
168
|
+
addressAddition2=None,
|
|
169
|
+
addressAddition3=lib.text(shipper.company_name, max=20),
|
|
170
|
+
)
|
|
171
|
+
if shipper.street
|
|
172
|
+
else None
|
|
173
|
+
),
|
|
174
|
+
# Parcel details (weight in grams)
|
|
175
|
+
parcel=hermes_req.ParcelType(
|
|
176
|
+
parcelClass=None, # Optional, calculated from dimensions
|
|
177
|
+
parcelHeight=lib.to_int(package.height.MM) if package.height else None,
|
|
178
|
+
parcelWidth=lib.to_int(package.width.MM) if package.width else None,
|
|
179
|
+
parcelDepth=lib.to_int(package.length.MM) if package.length else None,
|
|
180
|
+
parcelWeight=lib.to_int(package.weight.G), # Weight in grams
|
|
181
|
+
parcelVolume=None, # Optional
|
|
182
|
+
productType=product_type,
|
|
183
|
+
),
|
|
184
|
+
# Services
|
|
185
|
+
service=service if any([
|
|
186
|
+
getattr(service, attr) for attr in dir(service)
|
|
187
|
+
if not attr.startswith('_') and getattr(service, attr) is not None
|
|
188
|
+
]) else None,
|
|
189
|
+
# Customs for international
|
|
190
|
+
customsAndTaxes=customs,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return lib.Serializable(request, lib.to_dict)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _build_service(options: units.ShippingOptions) -> hermes_req.ServiceType:
|
|
197
|
+
"""Build Hermes service object from shipping options."""
|
|
198
|
+
# Cash on delivery
|
|
199
|
+
cod_service = None
|
|
200
|
+
if options.hermes_cod_amount.state:
|
|
201
|
+
cod_service = hermes_req.CashOnDeliveryServiceType(
|
|
202
|
+
amount=options.hermes_cod_amount.state,
|
|
203
|
+
currency=options.hermes_cod_currency.state or "EUR",
|
|
204
|
+
bankTransferAmount=options.hermes_cod_amount.state,
|
|
205
|
+
bankTransferCurrency=options.hermes_cod_currency.state or "EUR",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Customer alert service
|
|
209
|
+
alert_service = None
|
|
210
|
+
if options.hermes_notification_email.state:
|
|
211
|
+
alert_service = hermes_req.CustomerAlertServiceType(
|
|
212
|
+
notificationType=options.hermes_notification_type.state or "EMAIL",
|
|
213
|
+
notificationEmail=options.hermes_notification_email.state,
|
|
214
|
+
notificationNumber=None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Ident service
|
|
218
|
+
ident_service = None
|
|
219
|
+
if options.hermes_ident_fsk.state or options.hermes_ident_id.state:
|
|
220
|
+
ident_service = hermes_req.IdentServiceType(
|
|
221
|
+
identID=options.hermes_ident_id.state,
|
|
222
|
+
identType=options.hermes_ident_type.state,
|
|
223
|
+
identVerifyFsk=options.hermes_ident_fsk.state,
|
|
224
|
+
identVerifyBirthday=options.hermes_ident_birthday.state,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Parcel shop delivery
|
|
228
|
+
parcel_shop_service = None
|
|
229
|
+
if options.hermes_parcel_shop_id.state:
|
|
230
|
+
parcel_shop_service = hermes_req.ParcelShopDeliveryServiceType(
|
|
231
|
+
psCustomerFirstName=options.hermes_parcel_shop_customer_firstname.state,
|
|
232
|
+
psCustomerLastName=options.hermes_parcel_shop_customer_lastname.state,
|
|
233
|
+
psID=options.hermes_parcel_shop_id.state,
|
|
234
|
+
psSelectionRule=options.hermes_parcel_shop_selection_rule.state or "SELECT_BY_ID",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Stated day service
|
|
238
|
+
stated_day_service = None
|
|
239
|
+
if options.hermes_stated_day.state:
|
|
240
|
+
stated_day_service = hermes_req.StatedDayServiceType(
|
|
241
|
+
statedDay=options.hermes_stated_day.state,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Stated time service
|
|
245
|
+
stated_time_service = None
|
|
246
|
+
if options.hermes_time_slot.state:
|
|
247
|
+
stated_time_service = hermes_req.StatedTimeServiceType(
|
|
248
|
+
timeSlot=options.hermes_time_slot.state,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Multipart service
|
|
252
|
+
multipart_service = None
|
|
253
|
+
if options.hermes_number_of_parts.state:
|
|
254
|
+
multipart_service = hermes_req.MultipartServiceType(
|
|
255
|
+
partNumber=options.hermes_part_number.state or 1,
|
|
256
|
+
numberOfParts=options.hermes_number_of_parts.state,
|
|
257
|
+
parentShipmentOrderID=options.hermes_parent_shipment_order_id.state,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return hermes_req.ServiceType(
|
|
261
|
+
tanService=options.hermes_tan_service.state,
|
|
262
|
+
multipartService=multipart_service,
|
|
263
|
+
limitedQuantitiesService=options.hermes_limited_quantities.state,
|
|
264
|
+
cashOnDeliveryService=cod_service,
|
|
265
|
+
bulkGoodService=options.hermes_bulk_goods.state,
|
|
266
|
+
statedTimeService=stated_time_service,
|
|
267
|
+
householdSignatureService=options.hermes_household_signature.state,
|
|
268
|
+
customerAlertService=alert_service,
|
|
269
|
+
parcelShopDeliveryService=parcel_shop_service,
|
|
270
|
+
compactParcelService=options.hermes_compact_parcel.state,
|
|
271
|
+
identService=ident_service,
|
|
272
|
+
statedDayService=stated_day_service,
|
|
273
|
+
nextDayService=options.hermes_next_day.state,
|
|
274
|
+
signatureService=options.hermes_signature.state,
|
|
275
|
+
redirectionProhibitedService=options.hermes_redirection_prohibited.state,
|
|
276
|
+
excludeParcelShopAuthorization=options.hermes_exclude_parcel_shop_auth.state,
|
|
277
|
+
lateInjectionService=options.hermes_late_injection.state,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _build_customs(
|
|
282
|
+
customs: models.Customs,
|
|
283
|
+
shipper,
|
|
284
|
+
) -> hermes_req.CustomsAndTaxesType:
|
|
285
|
+
"""Build customs and taxes for international shipments."""
|
|
286
|
+
items = [
|
|
287
|
+
hermes_req.ItemType(
|
|
288
|
+
sku=item.sku,
|
|
289
|
+
category=None,
|
|
290
|
+
countryCodeOfManufacture=item.origin_country,
|
|
291
|
+
value=lib.to_int(item.value_amount * 100) if item.value_amount else None, # In cents
|
|
292
|
+
weight=lib.to_int(item.weight * 1000) if item.weight else None, # In grams
|
|
293
|
+
quantity=item.quantity or 1,
|
|
294
|
+
description=item.description or item.title,
|
|
295
|
+
exportDescription=None,
|
|
296
|
+
exportHsCode=None,
|
|
297
|
+
hsCode=item.hs_code,
|
|
298
|
+
url=None,
|
|
299
|
+
)
|
|
300
|
+
for item in customs.commodities or []
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
shipper_firstname, shipper_lastname = _split_name(shipper.person_name) if shipper else (None, None)
|
|
304
|
+
|
|
305
|
+
return hermes_req.CustomsAndTaxesType(
|
|
306
|
+
currency=lib.identity(customs.duty.currency if customs.duty else "EUR"),
|
|
307
|
+
shipmentCost=None,
|
|
308
|
+
items=items or None,
|
|
309
|
+
invoiceReferences=None,
|
|
310
|
+
value=None,
|
|
311
|
+
exportCustomsClearance=None,
|
|
312
|
+
client=None,
|
|
313
|
+
shipmentOriginAddress=lib.identity(
|
|
314
|
+
hermes_req.ShipmentOriginAddressType(
|
|
315
|
+
title=None,
|
|
316
|
+
firstname=shipper_firstname,
|
|
317
|
+
lastname=shipper_lastname,
|
|
318
|
+
company=shipper.company_name,
|
|
319
|
+
street=shipper.street_name,
|
|
320
|
+
houseNumber=shipper.street_number or "",
|
|
321
|
+
zipCode=shipper.postal_code,
|
|
322
|
+
town=shipper.city,
|
|
323
|
+
state=shipper.state_code,
|
|
324
|
+
countryCode=shipper.country_code,
|
|
325
|
+
addressAddition=shipper.address_line2,
|
|
326
|
+
addressAddition2=None,
|
|
327
|
+
addressAddition3=None,
|
|
328
|
+
phone=shipper.phone_number,
|
|
329
|
+
fax=None,
|
|
330
|
+
mobile=None,
|
|
331
|
+
mail=shipper.email,
|
|
332
|
+
)
|
|
333
|
+
if shipper
|
|
334
|
+
else None
|
|
335
|
+
),
|
|
336
|
+
)
|
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
shipping_options = lib.OptionEnum("shipping_options", list)
|
|
12
|
+
shipping_services = lib.OptionEnum("shipping_services", list)
|
|
13
|
+
label_type = lib.OptionEnum("label_type", str, "PDF")
|
|
14
|
+
language = lib.OptionEnum("language", str, "DE") # DE or EN
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ParcelClass(lib.StrEnum):
|
|
18
|
+
"""Hermes parcel size classes."""
|
|
19
|
+
|
|
20
|
+
XS = "XS"
|
|
21
|
+
S = "S"
|
|
22
|
+
M = "M"
|
|
23
|
+
L = "L"
|
|
24
|
+
XL = "XL"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProductType(lib.StrEnum):
|
|
28
|
+
"""Hermes product types."""
|
|
29
|
+
|
|
30
|
+
BAG = "BAG"
|
|
31
|
+
BIKE = "BIKE"
|
|
32
|
+
LARGE_ITEM = "LARGE_ITEM"
|
|
33
|
+
PARCEL = "PARCEL"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PackagingType(lib.StrEnum):
|
|
37
|
+
"""Carrier specific packaging type."""
|
|
38
|
+
|
|
39
|
+
hermes_parcel = "PARCEL"
|
|
40
|
+
hermes_bag = "BAG"
|
|
41
|
+
hermes_bike = "BIKE"
|
|
42
|
+
hermes_large_item = "LARGE_ITEM"
|
|
43
|
+
|
|
44
|
+
"""Unified Packaging type mapping."""
|
|
45
|
+
envelope = hermes_parcel
|
|
46
|
+
pak = hermes_parcel
|
|
47
|
+
tube = hermes_parcel
|
|
48
|
+
pallet = hermes_large_item
|
|
49
|
+
small_box = hermes_parcel
|
|
50
|
+
medium_box = hermes_parcel
|
|
51
|
+
your_packaging = hermes_parcel
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ShippingService(lib.StrEnum):
|
|
55
|
+
"""Carrier specific services."""
|
|
56
|
+
|
|
57
|
+
hermes_standard = "hermes_standard"
|
|
58
|
+
hermes_next_day = "hermes_next_day"
|
|
59
|
+
hermes_stated_day = "hermes_stated_day"
|
|
60
|
+
hermes_parcel_shop = "hermes_parcel_shop"
|
|
61
|
+
hermes_international = "hermes_international"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ShippingOption(lib.Enum):
|
|
65
|
+
"""Carrier specific options."""
|
|
66
|
+
|
|
67
|
+
# Hermes services as options
|
|
68
|
+
hermes_tan_service = lib.OptionEnum("tanService", bool)
|
|
69
|
+
hermes_limited_quantities = lib.OptionEnum("limitedQuantitiesService", bool)
|
|
70
|
+
hermes_bulk_goods = lib.OptionEnum("bulkGoodService", bool)
|
|
71
|
+
hermes_household_signature = lib.OptionEnum("householdSignatureService", bool)
|
|
72
|
+
hermes_compact_parcel = lib.OptionEnum("compactParcelService", bool)
|
|
73
|
+
hermes_next_day = lib.OptionEnum("nextDayService", bool)
|
|
74
|
+
hermes_signature = lib.OptionEnum("signatureService", bool)
|
|
75
|
+
hermes_redirection_prohibited = lib.OptionEnum("redirectionProhibitedService", bool)
|
|
76
|
+
hermes_exclude_parcel_shop_auth = lib.OptionEnum("excludeParcelShopAuthorization", bool)
|
|
77
|
+
hermes_late_injection = lib.OptionEnum("lateInjectionService", bool)
|
|
78
|
+
|
|
79
|
+
# Cash on delivery
|
|
80
|
+
hermes_cod_amount = lib.OptionEnum("codAmount", float)
|
|
81
|
+
hermes_cod_currency = lib.OptionEnum("codCurrency", str)
|
|
82
|
+
|
|
83
|
+
# Customer alert service
|
|
84
|
+
hermes_notification_email = lib.OptionEnum("notificationEmail", str)
|
|
85
|
+
hermes_notification_type = lib.OptionEnum("notificationType", str) # EMAIL, SMS, EMAIL_SMS
|
|
86
|
+
|
|
87
|
+
# Stated day service
|
|
88
|
+
hermes_stated_day = lib.OptionEnum("statedDay", str) # YYYY-MM-DD format
|
|
89
|
+
|
|
90
|
+
# Stated time service
|
|
91
|
+
hermes_time_slot = lib.OptionEnum("timeSlot", str) # FORENOON, NOON, AFTERNOON, EVENING
|
|
92
|
+
|
|
93
|
+
# Ident service
|
|
94
|
+
hermes_ident_id = lib.OptionEnum("identID", str)
|
|
95
|
+
hermes_ident_type = lib.OptionEnum("identType", str) # GERMAN_IDENTITY_CARD, etc.
|
|
96
|
+
hermes_ident_fsk = lib.OptionEnum("identVerifyFsk", str) # 18
|
|
97
|
+
hermes_ident_birthday = lib.OptionEnum("identVerifyBirthday", str) # YYYY-MM-DD
|
|
98
|
+
|
|
99
|
+
# Parcel shop delivery
|
|
100
|
+
hermes_parcel_shop_id = lib.OptionEnum("psID", str)
|
|
101
|
+
hermes_parcel_shop_selection_rule = lib.OptionEnum("psSelectionRule", str) # SELECT_BY_ID, SELECT_BY_RECEIVER_ADDRESS
|
|
102
|
+
hermes_parcel_shop_customer_firstname = lib.OptionEnum("psCustomerFirstName", str)
|
|
103
|
+
hermes_parcel_shop_customer_lastname = lib.OptionEnum("psCustomerLastName", str)
|
|
104
|
+
|
|
105
|
+
# Multipart service
|
|
106
|
+
hermes_part_number = lib.OptionEnum("partNumber", int)
|
|
107
|
+
hermes_number_of_parts = lib.OptionEnum("numberOfParts", int)
|
|
108
|
+
hermes_parent_shipment_order_id = lib.OptionEnum("parentShipmentOrderID", str)
|
|
109
|
+
|
|
110
|
+
"""Unified Option type mapping."""
|
|
111
|
+
signature_required = hermes_signature
|
|
112
|
+
cash_on_delivery = hermes_cod_amount
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def shipping_options_initializer(
|
|
116
|
+
options: dict,
|
|
117
|
+
package_options: units.ShippingOptions = None,
|
|
118
|
+
) -> units.ShippingOptions:
|
|
119
|
+
"""Apply default values to the given options."""
|
|
120
|
+
|
|
121
|
+
if package_options is not None:
|
|
122
|
+
options.update(package_options.content)
|
|
123
|
+
|
|
124
|
+
def items_filter(key: str) -> bool:
|
|
125
|
+
return key in ShippingOption # type: ignore
|
|
126
|
+
|
|
127
|
+
return units.ShippingOptions(options, ShippingOption, items_filter=items_filter)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TrackingStatus(lib.Enum):
|
|
131
|
+
"""Hermes tracking status mapping."""
|
|
132
|
+
|
|
133
|
+
on_hold = ["on_hold"]
|
|
134
|
+
delivered = ["delivered"]
|
|
135
|
+
in_transit = ["in_transit"]
|
|
136
|
+
delivery_failed = ["delivery_failed"]
|
|
137
|
+
delivery_delayed = ["delivery_delayed"]
|
|
138
|
+
out_for_delivery = ["out_for_delivery"]
|
|
139
|
+
ready_for_pickup = ["ready_for_pickup"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class LabelType(lib.StrEnum):
|
|
143
|
+
"""Hermes label formats - use +json variants for JSON response with base64 label."""
|
|
144
|
+
|
|
145
|
+
PDF = "application/shippinglabel-pdf+json"
|
|
146
|
+
ZPL = "application/shippinglabel-zpl+json;dpi=300"
|
|
147
|
+
PNG = "application/shippinglabel-data+json"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class PickupTimeSlot(lib.StrEnum):
|
|
151
|
+
"""Hermes pickup time slots per OpenAPI spec."""
|
|
152
|
+
|
|
153
|
+
BETWEEN_10_AND_13 = "BETWEEN_10_AND_13"
|
|
154
|
+
BETWEEN_12_AND_15 = "BETWEEN_12_AND_15"
|
|
155
|
+
BETWEEN_14_AND_17 = "BETWEEN_14_AND_17"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load_services_from_csv() -> list:
|
|
159
|
+
"""
|
|
160
|
+
Load service definitions from CSV file.
|
|
161
|
+
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
|
|
162
|
+
"""
|
|
163
|
+
csv_path = pathlib.Path(__file__).resolve().parent / "services.csv"
|
|
164
|
+
|
|
165
|
+
if not csv_path.exists():
|
|
166
|
+
# Fallback to simple default if CSV doesn't exist
|
|
167
|
+
return [
|
|
168
|
+
models.ServiceLevel(
|
|
169
|
+
service_name="Hermes Standard",
|
|
170
|
+
service_code="hermes_standard",
|
|
171
|
+
currency="EUR",
|
|
172
|
+
domicile=True,
|
|
173
|
+
zones=[models.ServiceZone(rate=0.0)],
|
|
174
|
+
)
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
# Group zones by service
|
|
178
|
+
services_dict: dict[str, dict] = {}
|
|
179
|
+
|
|
180
|
+
with open(csv_path, "r", encoding="utf-8") as f:
|
|
181
|
+
reader = csv.DictReader(f)
|
|
182
|
+
for row in reader:
|
|
183
|
+
service_code = row["service_code"]
|
|
184
|
+
service_name = row["service_name"]
|
|
185
|
+
|
|
186
|
+
# Map carrier service code to karrio service code
|
|
187
|
+
karrio_service_code = ShippingService.map(service_code).name_or_key
|
|
188
|
+
|
|
189
|
+
# Initialize service if not exists
|
|
190
|
+
if karrio_service_code not in services_dict:
|
|
191
|
+
services_dict[karrio_service_code] = {
|
|
192
|
+
"service_name": service_name,
|
|
193
|
+
"service_code": karrio_service_code,
|
|
194
|
+
"currency": row.get("currency", "EUR"),
|
|
195
|
+
"min_weight": (
|
|
196
|
+
float(row["min_weight"]) if row.get("min_weight") else None
|
|
197
|
+
),
|
|
198
|
+
"max_weight": (
|
|
199
|
+
float(row["max_weight"]) if row.get("max_weight") else None
|
|
200
|
+
),
|
|
201
|
+
"max_length": (
|
|
202
|
+
float(row["max_length"]) if row.get("max_length") else None
|
|
203
|
+
),
|
|
204
|
+
"max_width": (
|
|
205
|
+
float(row["max_width"]) if row.get("max_width") else None
|
|
206
|
+
),
|
|
207
|
+
"max_height": (
|
|
208
|
+
float(row["max_height"]) if row.get("max_height") else None
|
|
209
|
+
),
|
|
210
|
+
"weight_unit": "KG",
|
|
211
|
+
"dimension_unit": "CM",
|
|
212
|
+
"domicile": row.get("domicile", "").lower() == "true",
|
|
213
|
+
"international": (
|
|
214
|
+
True if row.get("international", "").lower() == "true" else None
|
|
215
|
+
),
|
|
216
|
+
"zones": [],
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# Parse country codes
|
|
220
|
+
country_codes = [
|
|
221
|
+
c.strip() for c in row.get("country_codes", "").split(",") if c.strip()
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
# Create zone
|
|
225
|
+
zone = models.ServiceZone(
|
|
226
|
+
label=row.get("zone_label", "Default Zone"),
|
|
227
|
+
rate=float(row.get("rate", 0.0)),
|
|
228
|
+
transit_days=(
|
|
229
|
+
int(row["transit_days"].split("-")[0]) if row.get("transit_days") and row["transit_days"].split("-")[0].isdigit() else None
|
|
230
|
+
),
|
|
231
|
+
country_codes=country_codes if country_codes else None,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
services_dict[karrio_service_code]["zones"].append(zone)
|
|
235
|
+
|
|
236
|
+
# Convert to ServiceLevel objects
|
|
237
|
+
return [
|
|
238
|
+
models.ServiceLevel(**service_data) for service_data in services_dict.values()
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
DEFAULT_SERVICES = load_services_from_csv()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import karrio.lib as lib
|
|
3
|
+
import karrio.core as core
|
|
4
|
+
import karrio.core.errors as errors
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Settings(core.Settings):
|
|
8
|
+
"""Hermes connection settings."""
|
|
9
|
+
|
|
10
|
+
# OAuth2 credentials (password flow)
|
|
11
|
+
username: str
|
|
12
|
+
password: str
|
|
13
|
+
client_id: str
|
|
14
|
+
client_secret: str
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def carrier_name(self):
|
|
18
|
+
return "hermes"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def server_url(self):
|
|
22
|
+
return (
|
|
23
|
+
"https://de-api-int.hermesworld.com/services/hsi"
|
|
24
|
+
if self.test_mode
|
|
25
|
+
else "https://de-api.hermesworld.com/services/hsi"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def token_url(self):
|
|
30
|
+
return (
|
|
31
|
+
"https://authme-int.myhermes.de/authorization-facade/oauth2/access_token"
|
|
32
|
+
if self.test_mode
|
|
33
|
+
else "https://authme.myhermes.de/authorization-facade/oauth2/access_token"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def connection_config(self) -> lib.units.Options:
|
|
38
|
+
from karrio.providers.hermes.units import ConnectionConfig
|
|
39
|
+
|
|
40
|
+
return lib.to_connection_config(
|
|
41
|
+
self.config or {},
|
|
42
|
+
option_type=ConnectionConfig,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def access_token(self):
|
|
47
|
+
"""Retrieve the access_token using the username|password pair
|
|
48
|
+
or collect it from the cache if an unexpired access_token exists.
|
|
49
|
+
"""
|
|
50
|
+
cache_key = f"{self.carrier_name}|{self.username}|{self.client_id}"
|
|
51
|
+
|
|
52
|
+
return self.connection_cache.thread_safe(
|
|
53
|
+
refresh_func=lambda: login(self),
|
|
54
|
+
cache_key=cache_key,
|
|
55
|
+
buffer_minutes=5,
|
|
56
|
+
).get_state()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def login(settings: Settings):
|
|
60
|
+
"""Authenticate with Hermes OAuth2 password flow."""
|
|
61
|
+
import karrio.providers.hermes.error as error
|
|
62
|
+
import karrio.core.models as models
|
|
63
|
+
|
|
64
|
+
result = lib.request(
|
|
65
|
+
url=settings.token_url,
|
|
66
|
+
trace=settings.trace_as("json"),
|
|
67
|
+
method="POST",
|
|
68
|
+
headers={
|
|
69
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
70
|
+
},
|
|
71
|
+
data=lib.to_query_string({
|
|
72
|
+
"grant_type": "password",
|
|
73
|
+
"username": settings.username,
|
|
74
|
+
"password": settings.password,
|
|
75
|
+
"client_id": settings.client_id,
|
|
76
|
+
"client_secret": settings.client_secret,
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
response = lib.to_dict(result)
|
|
80
|
+
|
|
81
|
+
# Handle case where response is not a dict
|
|
82
|
+
if not isinstance(response, dict):
|
|
83
|
+
raise errors.ParsedMessagesError(
|
|
84
|
+
messages=[
|
|
85
|
+
models.Message(
|
|
86
|
+
carrier_id=settings.carrier_id,
|
|
87
|
+
carrier_name=settings.carrier_name,
|
|
88
|
+
code="AUTH_ERROR",
|
|
89
|
+
message=f"Authentication failed - unexpected response: {str(response)[:200]}",
|
|
90
|
+
)
|
|
91
|
+
]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
messages = error.parse_error_response(response, settings)
|
|
95
|
+
|
|
96
|
+
if any(messages):
|
|
97
|
+
raise errors.ParsedMessagesError(messages=messages)
|
|
98
|
+
|
|
99
|
+
expiry = datetime.datetime.now() + datetime.timedelta(
|
|
100
|
+
seconds=float(response.get("expires_in", 3600))
|
|
101
|
+
)
|
|
102
|
+
return {**response, "expiry": lib.fdatetime(expiry)}
|