From 6fe2151accdfd5fc09e26cd9c16d8e4d53eacd9c Mon Sep 17 00:00:00 2001 From: Jan Suhr Date: Mon, 15 Jun 2026 11:49:14 +0200 Subject: [PATCH 1/4] support Packstation --- delivery_dhl_parcel_de/README.rst | 14 ++++ .../models/delivery_carrier.py | 72 +++++++++++++++---- delivery_dhl_parcel_de/tests/__init__.py | 1 + .../tests/test_packstation.py | 31 ++++++++ 4 files changed, 103 insertions(+), 15 deletions(-) create mode 100644 delivery_dhl_parcel_de/tests/__init__.py create mode 100644 delivery_dhl_parcel_de/tests/test_packstation.py diff --git a/delivery_dhl_parcel_de/README.rst b/delivery_dhl_parcel_de/README.rst index f9ea7428..599e1434 100644 --- a/delivery_dhl_parcel_de/README.rst +++ b/delivery_dhl_parcel_de/README.rst @@ -9,9 +9,23 @@ Features * Create DHL Parcel DE shipments directly from Odoo * Update tracking information in Odoo from DHL * Generate and print shipping labels in Odoo +* Deliver to DHL Packstations (lockers) Note that shipping rates are not retrieved from DHL but need to be configured. +Packstation (Locker) Delivery +------------------------------ + +To ship to a DHL Packstation, set the recipient's address fields as follows: + +* **Street 1**: The recipient's DHL post number (8-digit customer number). +* **Street 2**: ``Packstation ``, e.g. ``Packstation 183`` or ``Packstation: 183`` (case-insensitive). +* **ZIP / City / Country**: The Packstation's postal address. + +The module detects the keyword ``Packstation`` in Street 2 and automatically +sends a locker-addressed shipment request to DHL instead of a regular contact +address. + Configuration ------------- diff --git a/delivery_dhl_parcel_de/models/delivery_carrier.py b/delivery_dhl_parcel_de/models/delivery_carrier.py index 3f9c4412..8f524775 100644 --- a/delivery_dhl_parcel_de/models/delivery_carrier.py +++ b/delivery_dhl_parcel_de/models/delivery_carrier.py @@ -1,6 +1,7 @@ import binascii import json import logging +import re import requests @@ -88,6 +89,27 @@ class DeliveryCarrier(models.Model): help="Obtained via Get Access! (app creation) and manually approved by DHL.", ) + @staticmethod + def _is_packstation(street2): + """Return True if street2 indicates a DHL Packstation (locker) delivery.""" + return bool(street2 and "packstation" in street2.lower()) + + @staticmethod + def _get_packstation_locker_id(street2): + """Extract the locker ID from a street2 field. + + Removes the keyword 'Packstation' (case-insensitive), any adjacent + colons and surrounding whitespace, returning only the numeric locker ID. + + Examples:: + + "Packstation 123" -> "123" + "Packstation: 456" -> "456" + "PACKSTATION : 789" -> "789" + """ + locker_id = re.sub(r"(?i)\s*packstation\s*:?\s*", "", street2).strip() + return locker_id + def _calculate_package_insurance(self, picking, package_weight, total_weight): """ Calculate insurance value for a package based on weight distribution. @@ -293,6 +315,40 @@ def dhl_parcel_de_provider_get_package_info(self, picking, insurance_value): + self.dhl_participation_no ) + if self._is_packstation(receiver_street2): + # Deliver to a DHL Packstation (locker). Street 1 holds the + # postNumber (DHL customer number / post number); Street 2 + # contains the Packstation keyword followed by the locker ID. + locker_id = self._get_packstation_locker_id(receiver_street2) + consignee = { + "name1": receiver_company or recipient_address_id.name, + "locker": { + "lockerID": locker_id, + "postNumber": receiver_street, + "postalCode": receiver_zip, + "city": receiver_city, + "country": receiver_country_code, + }, + "email": receiver_email, + "phone": receiver_phone, + } + if receiver_company: + consignee["name2"] = recipient_address_id.name + else: + consignee = { + "name1": receiver_company or recipient_address_id.name, + "name2": recipient_address_id.name + if receiver_company + else receiver_street2, + "name3": receiver_street2 if receiver_company else "", + "addressStreet": receiver_street, + "postalCode": receiver_zip, + "city": receiver_city, + "country": receiver_country_code, + "email": receiver_email, + "phone": receiver_phone, + } + package_data = { "product": self.dhl_services_name, "billingNumber": billingNumber, @@ -307,21 +363,7 @@ def dhl_parcel_de_provider_get_package_info(self, picking, insurance_value): "email": sender_email, "phone": sender_phone, }, - "consignee": { - "name1": receiver_company - if receiver_company - else recipient_address_id.name, - "name2": recipient_address_id.name - if receiver_company - else receiver_street2, - "name3": receiver_street2 if receiver_company else "", - "addressStreet": receiver_street, - "postalCode": receiver_zip, - "city": receiver_city, - "country": receiver_country_code, - "email": receiver_email, - "phone": receiver_phone, - }, + "consignee": consignee, } europe_group_id = self.env.ref("base.europe") diff --git a/delivery_dhl_parcel_de/tests/__init__.py b/delivery_dhl_parcel_de/tests/__init__.py new file mode 100644 index 00000000..ba10de5d --- /dev/null +++ b/delivery_dhl_parcel_de/tests/__init__.py @@ -0,0 +1 @@ +from . import test_packstation diff --git a/delivery_dhl_parcel_de/tests/test_packstation.py b/delivery_dhl_parcel_de/tests/test_packstation.py new file mode 100644 index 00000000..0993ffdf --- /dev/null +++ b/delivery_dhl_parcel_de/tests/test_packstation.py @@ -0,0 +1,31 @@ +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from odoo.addons.delivery_dhl_parcel_de.models.delivery_carrier import DeliveryCarrier + + +@tagged("post_install", "-at_install") +class TestPackstation(TransactionCase): + """Unit tests for DHL Packstation (locker) address parsing helpers.""" + + def test_is_packstation(self): + """Street2 containing 'Packstation' (any case) is detected as a locker.""" + self.assertTrue(DeliveryCarrier._is_packstation("Packstation 123")) + self.assertTrue(DeliveryCarrier._is_packstation("PACKSTATION 456")) + self.assertTrue(DeliveryCarrier._is_packstation("packstation 789")) + + def test_is_packstation_false(self): + """Normal street2 values, empty strings and None are not Packstations.""" + self.assertFalse(DeliveryCarrier._is_packstation("Apartment 2B")) + self.assertFalse(DeliveryCarrier._is_packstation("")) + self.assertFalse(DeliveryCarrier._is_packstation(None)) + + def test_get_locker_id_simple(self): + """Locker ID is extracted from 'Packstation NNN' formats.""" + self.assertEqual(DeliveryCarrier._get_packstation_locker_id("Packstation 123"), "123") + self.assertEqual(DeliveryCarrier._get_packstation_locker_id("PACKSTATION 456"), "456") + + def test_get_locker_id_with_colon(self): + """Locker ID is extracted when a colon separates the keyword from the ID.""" + self.assertEqual(DeliveryCarrier._get_packstation_locker_id("Packstation: 456"), "456") + self.assertEqual(DeliveryCarrier._get_packstation_locker_id("PACKSTATION : 789"), "789") From b8d565acf78336fe49d8e6f8df20d9873c759077 Mon Sep 17 00:00:00 2001 From: Jan Suhr Date: Mon, 15 Jun 2026 12:01:52 +0200 Subject: [PATCH 2/4] fix syntax --- delivery_dhl_parcel_de/tests/test_packstation.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/delivery_dhl_parcel_de/tests/test_packstation.py b/delivery_dhl_parcel_de/tests/test_packstation.py index 0993ffdf..42e37592 100644 --- a/delivery_dhl_parcel_de/tests/test_packstation.py +++ b/delivery_dhl_parcel_de/tests/test_packstation.py @@ -22,10 +22,18 @@ def test_is_packstation_false(self): def test_get_locker_id_simple(self): """Locker ID is extracted from 'Packstation NNN' formats.""" - self.assertEqual(DeliveryCarrier._get_packstation_locker_id("Packstation 123"), "123") - self.assertEqual(DeliveryCarrier._get_packstation_locker_id("PACKSTATION 456"), "456") + self.assertEqual( + DeliveryCarrier._get_packstation_locker_id("Packstation 123"), "123" + ) + self.assertEqual( + DeliveryCarrier._get_packstation_locker_id("PACKSTATION 456"), "456" + ) def test_get_locker_id_with_colon(self): """Locker ID is extracted when a colon separates the keyword from the ID.""" - self.assertEqual(DeliveryCarrier._get_packstation_locker_id("Packstation: 456"), "456") - self.assertEqual(DeliveryCarrier._get_packstation_locker_id("PACKSTATION : 789"), "789") + self.assertEqual( + DeliveryCarrier._get_packstation_locker_id("Packstation: 456"), "456" + ) + self.assertEqual( + DeliveryCarrier._get_packstation_locker_id("PACKSTATION : 789"), "789" + ) From 1b348a1e021c4da64068411a678f6705ff3f0b7b Mon Sep 17 00:00:00 2001 From: Jan Suhr Date: Mon, 15 Jun 2026 14:53:08 +0200 Subject: [PATCH 3/4] fix API query --- delivery_dhl_parcel_de/README.rst | 6 +++-- .../models/delivery_carrier.py | 22 ++++++------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/delivery_dhl_parcel_de/README.rst b/delivery_dhl_parcel_de/README.rst index 599e1434..e3e85c54 100644 --- a/delivery_dhl_parcel_de/README.rst +++ b/delivery_dhl_parcel_de/README.rst @@ -18,8 +18,10 @@ Packstation (Locker) Delivery To ship to a DHL Packstation, set the recipient's address fields as follows: -* **Street 1**: The recipient's DHL post number (8-digit customer number). -* **Street 2**: ``Packstation ``, e.g. ``Packstation 183`` or ``Packstation: 183`` (case-insensitive). +* **Street 1**: The recipient's DHL post number (6–10 digits). +* **Street 2**: ``Packstation ``, e.g. ``Packstation 183`` or + ``Packstation: 183`` (case-insensitive). The locker ID is a 3-digit integer + (100–999) identifying the Packstation. * **ZIP / City / Country**: The Packstation's postal address. The module detects the keyword ``Packstation`` in Street 2 and automatically diff --git a/delivery_dhl_parcel_de/models/delivery_carrier.py b/delivery_dhl_parcel_de/models/delivery_carrier.py index 8f524775..5556a0e2 100644 --- a/delivery_dhl_parcel_de/models/delivery_carrier.py +++ b/delivery_dhl_parcel_de/models/delivery_carrier.py @@ -316,24 +316,16 @@ def dhl_parcel_de_provider_get_package_info(self, picking, insurance_value): ) if self._is_packstation(receiver_street2): - # Deliver to a DHL Packstation (locker). Street 1 holds the - # postNumber (DHL customer number / post number); Street 2 - # contains the Packstation keyword followed by the locker ID. + # Deliver to a DHL Packstation (locker). locker_id = self._get_packstation_locker_id(receiver_street2) consignee = { - "name1": receiver_company or recipient_address_id.name, - "locker": { - "lockerID": locker_id, - "postNumber": receiver_street, - "postalCode": receiver_zip, - "city": receiver_city, - "country": receiver_country_code, - }, - "email": receiver_email, - "phone": receiver_phone, + "name": receiver_company or recipient_address_id.name, + "lockerID": int(locker_id), + "postNumber": receiver_street, + "postalCode": receiver_zip, + "city": receiver_city, + "country": receiver_country_code, } - if receiver_company: - consignee["name2"] = recipient_address_id.name else: consignee = { "name1": receiver_company or recipient_address_id.name, From 8ccfe396d85599c63ed38a71cb2da8b7ec16fee0 Mon Sep 17 00:00:00 2001 From: Jan Suhr Date: Mon, 15 Jun 2026 15:43:34 +0200 Subject: [PATCH 4/4] fix API format --- delivery_dhl_parcel_de/models/delivery_carrier.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/delivery_dhl_parcel_de/models/delivery_carrier.py b/delivery_dhl_parcel_de/models/delivery_carrier.py index 5556a0e2..00641300 100644 --- a/delivery_dhl_parcel_de/models/delivery_carrier.py +++ b/delivery_dhl_parcel_de/models/delivery_carrier.py @@ -316,15 +316,17 @@ def dhl_parcel_de_provider_get_package_info(self, picking, insurance_value): ) if self._is_packstation(receiver_street2): - # Deliver to a DHL Packstation (locker). locker_id = self._get_packstation_locker_id(receiver_street2) consignee = { - "name": receiver_company or recipient_address_id.name, - "lockerID": int(locker_id), - "postNumber": receiver_street, + "name1": receiver_company or recipient_address_id.name, + "name2": receiver_street, + "addressStreet": "Packstation", + "addressHouse": locker_id, "postalCode": receiver_zip, "city": receiver_city, "country": receiver_country_code, + "email": receiver_email, + "phone": receiver_phone, } else: consignee = {