From b86aa7c67f09c1878c0de6798a69b6cac2cd6945 Mon Sep 17 00:00:00 2001 From: hansdaigle Date: Tue, 12 May 2026 00:13:50 -0400 Subject: [PATCH] fix(canadapost): normalize tracking event statuses --- .../karrio/providers/canadapost/tracking.py | 40 +- .../karrio/providers/canadapost/units.py | 361 ++++++++++++------ .../tests/canadapost/test_tracking.py | 49 ++- 3 files changed, 306 insertions(+), 144 deletions(-) diff --git a/modules/connectors/canadapost/karrio/providers/canadapost/tracking.py b/modules/connectors/canadapost/karrio/providers/canadapost/tracking.py index d98f3807bd..b537e9c9f0 100644 --- a/modules/connectors/canadapost/karrio/providers/canadapost/tracking.py +++ b/modules/connectors/canadapost/karrio/providers/canadapost/tracking.py @@ -12,7 +12,16 @@ def parse_tracking_response( settings: provider_utils.Settings, ) -> typing.Tuple[typing.List[models.TrackingDetails], typing.List[models.Message]]: response = _response.deserialize() - details = lib.find_element("tracking-detail", response) + responses = lib.to_list(response) + details: typing.List[lib.Element] = [] + for node in responses: + tag = getattr(node, "tag", "") + local_name = tag.split("}")[-1] if isinstance(tag, str) else "" + # Canada Post may return as the response root. + if local_name == "tracking-detail": + details.append(node) + continue + details.extend(lib.find_element("tracking-detail", node)) tracking_details: typing.List[models.TrackingDetails] = [ _extract_tracking(node, settings) for node in details @@ -33,13 +42,9 @@ def _extract_tracking( details.changed_expected_date or details.expected_delivery_date, "%Y-%m-%d", ) - status = next( - ( - status.name - for status in list(provider_units.TrackingStatus) - if last_event.event_identifier in status.value - ), - provider_units.TrackingStatus.in_transit.name, + status = provider_units.map_tracking_status( + last_event.event_identifier, + last_event.event_description, ) return models.TrackingDetails( @@ -62,21 +67,12 @@ def _extract_tracking( lib.fdate(event.event_date, "%Y-%m-%d"), lib.ftime(event.event_time, "%H:%M:%S"), ), - status=next( - ( - s.name - for s in list(provider_units.TrackingStatus) - if event.event_identifier in s.value - ), - None, + status=provider_units.map_tracking_status( + event.event_identifier, + event.event_description, ), - reason=next( - ( - r.name - for r in list(provider_units.TrackingIncidentReason) - if event.event_identifier in r.value - ), - None, + reason=provider_units.map_tracking_incident_reason( + event.event_identifier, ), ) for event in events diff --git a/modules/connectors/canadapost/karrio/providers/canadapost/units.py b/modules/connectors/canadapost/karrio/providers/canadapost/units.py index 76dab61c81..6eec5e7fdf 100644 --- a/modules/connectors/canadapost/karrio/providers/canadapost/units.py +++ b/modules/connectors/canadapost/karrio/providers/canadapost/units.py @@ -170,126 +170,247 @@ def items_filter(key: str) -> bool: ) -class TrackingStatus(lib.Enum): - """Carrier tracking status mapping""" - - delivered = [ - "1408", - "1409", - "1421", - "1422", - "1423", - "1424", - "1425", - "1426", - "1427", - "1428", - "1429", - "1430", - "1431", - "1432", - "1433", - "1434", - "1441", - "1442", - "1496", - "1497", - "1498", - "1499", - ] - in_transit = [""] - picked_up = [ - "100", - "101", - "102", - "103", - "104", - "105", - "106", - "107", - "1400", - "1401", - "1402", - "1403", - "1404", - "1405", - ] - on_hold = [ - "117", - "120", - "121", - "125", - "127", - "810", - "1411", - "1414", - "1443", - "1484", - "1487", - "1494", - "2411", - "2414", - "4700", - ] - ready_for_pickup = [ - "118", - "156", - "1407", - "1410", - "1435", - "1436", - "1437", - "1438", - "1479", - "1488", - "1701", - "2410", - ] - delivery_failed = [ - "150", - "154", - "167", - "168", - "169", - "167", - "168", - "169", - "179", - "181", - "182", - "183", - "184", - "190", - "1100", - "1415", - "1416", - "1417", - "1418", - "1419", - "1420", - "1450", - "1481", - "1482", - "1483", - "1491", - "1492", - "1493", - "2600", - "2802", - "3001", - "4650", - ] - delivery_delayed = [ - "159", - "160", - "161", - "162", - "163", - "172", - "173", - "2412", - ] - out_for_delivery = ["174", "500"] +# Canonical Canada Post event mapping used for tracker status normalization. +# Sources: +# - Official Canada Post message/event code table: +# https://www.canadapost-postescanada.ca/info/mc/business/productsservices/developers/messagescodetables.jsf +# - Live event payloads observed in production integrations, including padded IDs like `0100`. +# +# Canada Post event descriptions pass through on TrackingEvent.description. +# Status normalization stays deterministic by mapping only the event code. +TRACKING_STATUS_MAPPING = { + "100": {"__default__": "picked_up"}, + "102": {"__default__": "in_transit"}, + "104": {"__default__": "picked_up"}, + "105": {"__default__": "picked_up"}, + "106": {"__default__": "in_transit"}, + "107": {"__default__": "picked_up"}, + "1100": {"__default__": "on_hold"}, + "113": {"__default__": "in_transit"}, + "114": {"__default__": "in_transit"}, + "115": {"__default__": "in_transit"}, + "116": {"__default__": "in_transit"}, + "117": {"__default__": "in_transit"}, + "118": {"__default__": "in_transit"}, + "120": {"__default__": "in_transit"}, + "1200": {"__default__": "on_hold"}, + "1203": {"__default__": "in_transit"}, + "121": {"__default__": "in_transit"}, + "1210": {"__default__": "in_transit"}, + "1214": {"__default__": "in_transit"}, + "1215": {"__default__": "in_transit"}, + "1216": {"__default__": "in_transit"}, + "1220": {"__default__": "in_transit"}, + "1221": {"__default__": "in_transit"}, + "1223": {"__default__": "in_transit"}, + "1224": {"__default__": "in_transit"}, + "1225": {"__default__": "in_transit"}, + "1230": {"__default__": "in_transit"}, + "1232": {"__default__": "in_transit"}, + "1234": {"__default__": "in_transit"}, + "1240": {"__default__": "picked_up"}, + "1241": {"__default__": "in_transit"}, + "1244": {"__default__": "picked_up"}, + "1245": {"__default__": "on_hold"}, + "127": {"__default__": "in_transit"}, + "130": {"__default__": "in_transit"}, + "1300": {"__default__": "pending"}, + "1301": {"__default__": "pending"}, + "1302": {"__default__": "pending"}, + "1303": {"__default__": "pending"}, + "1405": {"__default__": "delivered"}, + "1406": {"__default__": "delivered"}, + "1407": {"__default__": "ready_for_pickup"}, + "1408": {"__default__": "delivered"}, + "1409": {"__default__": "delivered"}, + "1410": {"__default__": "delivery_delayed"}, + "1411": {"__default__": "on_hold"}, + "1412": {"__default__": "on_hold"}, + "1414": {"__default__": "delivery_delayed"}, + "1415": {"__default__": "return_to_sender"}, + "1416": {"__default__": "return_to_sender"}, + "1417": {"__default__": "return_to_sender"}, + "1418": {"__default__": "return_to_sender"}, + "1419": {"__default__": "return_to_sender"}, + "1420": {"__default__": "return_to_sender"}, + "1421": {"__default__": "delivered"}, + "1422": {"__default__": "delivered"}, + "1423": {"__default__": "delivered"}, + "1424": {"__default__": "delivered"}, + "1425": {"__default__": "delivered"}, + "1426": {"__default__": "delivered"}, + "1427": {"__default__": "delivered"}, + "1428": {"__default__": "delivered"}, + "1429": {"__default__": "delivered"}, + "1430": {"__default__": "delivered"}, + "1431": {"__default__": "delivered"}, + "1432": {"__default__": "delivered"}, + "1433": {"__default__": "delivered"}, + "1434": {"__default__": "delivered"}, + "1435": {"__default__": "ready_for_pickup"}, + "1436": {"__default__": "ready_for_pickup"}, + "1437": {"__default__": "ready_for_pickup"}, + "1438": {"__default__": "ready_for_pickup"}, + "1441": {"__default__": "delivered"}, + "1442": {"__default__": "delivered"}, + "1443": {"__default__": "on_hold"}, + "1444": {"__default__": "on_hold"}, + "1450": {"__default__": "on_hold"}, + "1461": {"__default__": "delivered"}, + "1462": {"__default__": "delivered"}, + "1463": {"__default__": "delivered"}, + "1465": {"__default__": "delivered"}, + "1466": {"__default__": "delivered"}, + "1467": {"__default__": "delivered"}, + "1468": {"__default__": "delivered"}, + "1469": {"__default__": "delivered"}, + "1471": {"__default__": "delivered"}, + "1472": {"__default__": "delivered"}, + "1473": {"__default__": "delivered"}, + "1475": {"__default__": "delivered"}, + "1476": {"__default__": "delivered"}, + "1479": {"__default__": "ready_for_pickup"}, + "1480": {"__default__": "out_for_delivery"}, + "1481": {"__default__": "return_to_sender"}, + "1482": {"__default__": "return_to_sender"}, + "1483": {"__default__": "on_hold"}, + "1484": {"__default__": "on_hold"}, + "1487": {"__default__": "on_hold"}, + "1488": {"__default__": "ready_for_pickup"}, + "1490": {"__default__": "out_for_delivery"}, + "1491": {"__default__": "return_to_sender"}, + "1492": {"__default__": "return_to_sender"}, + "1493": {"__default__": "on_hold"}, + "1494": {"__default__": "on_hold"}, + "1495": {"__default__": "return_to_sender"}, + "1496": {"__default__": "delivered"}, + "1498": {"__default__": "delivered"}, + "150": {"__default__": "in_transit"}, + "152": {"__default__": "in_transit"}, + "156": {"__default__": "on_hold"}, + "159": {"__default__": "on_hold"}, + "160": {"__default__": "on_hold"}, + "161": {"__default__": "on_hold"}, + "162": {"__default__": "on_hold"}, + "163": {"__default__": "on_hold"}, + "167": {"__default__": "return_to_sender"}, + "168": {"__default__": "return_to_sender"}, + "169": {"__default__": "return_to_sender"}, + "170": {"__default__": "picked_up"}, + "1701": {"__default__": "ready_for_pickup"}, + "1703": {"__default__": "in_transit"}, + "1705": {"__default__": "ready_for_pickup"}, + "171": {"__default__": "in_transit"}, + "172": {"__default__": "on_hold"}, + "173": {"__default__": "on_hold"}, + "174": {"__default__": "out_for_delivery"}, + "175": {"__default__": "in_transit"}, + "179": {"__default__": "in_transit"}, + "181": {"__default__": "return_to_sender"}, + "182": {"__default__": "return_to_sender"}, + "183": {"__default__": "return_to_sender"}, + "184": {"__default__": "return_to_sender"}, + "190": {"__default__": "in_transit"}, + "198": {"__default__": "in_transit"}, + "20": {"__default__": "delivered"}, + "2001": {"__default__": "delivered"}, + "21": {"__default__": "delivered"}, + "2101": {"__default__": "in_transit"}, + "2300": {"__default__": "picked_up"}, + "2407": {"__default__": "ready_for_pickup"}, + "2410": {"__default__": "delivery_delayed"}, + "2411": {"__default__": "on_hold"}, + "2412": {"__default__": "on_hold"}, + "2414": {"__default__": "delivery_delayed"}, + "2500": {"__default__": "picked_up"}, + "2501": {"__default__": "picked_up"}, + "2600": {"__default__": "return_to_sender"}, + "2601": {"__default__": "return_to_sender"}, + "2802": {"__default__": "return_to_sender"}, + "3000": {"__default__": "pending"}, + "3001": {"__default__": "return_to_sender"}, + "3002": {"__default__": "pending"}, + "400": {"__default__": "in_transit"}, + "4000": {"__default__": "in_transit"}, + "405": {"__default__": "in_transit"}, + "410": {"__default__": "in_transit"}, + "4100": {"__default__": "in_transit"}, + "4202": {"__default__": "in_transit"}, + "4310": {"__default__": "in_transit"}, + "4311": {"__default__": "in_transit"}, + "4330": {"__default__": "in_transit"}, + "4400": {"__default__": "in_transit"}, + "4450": {"__default__": "in_transit"}, + "4500": {"__default__": "in_transit"}, + "4550": {"__default__": "in_transit"}, + "4600": {"__default__": "in_transit"}, + "4650": {"__default__": "return_to_sender"}, + "4700": {"__default__": "on_hold"}, + "4900": {"__default__": "in_transit"}, + "4950": {"__default__": "in_transit"}, + "500": {"__default__": "out_for_delivery"}, + "5201": {"__default__": "on_hold"}, + "610": {"__default__": "in_transit"}, + "611": {"__default__": "in_transit"}, + "612": {"__default__": "in_transit"}, + "613": {"__default__": "in_transit"}, + "614": {"__default__": "in_transit"}, + "615": {"__default__": "in_transit"}, + "616": {"__default__": "in_transit"}, + "617": {"__default__": "in_transit"}, + "618": {"__default__": "in_transit"}, + "619": {"__default__": "in_transit"}, + "620": {"__default__": "in_transit"}, + "621": {"__default__": "delivery_delayed"}, + "622": {"__default__": "delivery_delayed"}, + "623": {"__default__": "delivery_delayed"}, + "624": {"__default__": "on_hold"}, + "625": {"__default__": "delivery_delayed"}, + "626": {"__default__": "on_hold"}, + "627": {"__default__": "delivery_delayed"}, + "628": {"__default__": "delivery_delayed"}, + "629": {"__default__": "in_transit"}, + "630": {"__default__": "on_hold"}, + "700": {"__default__": "in_transit"}, + "701": {"__default__": "in_transit"}, + "710": {"__default__": "in_transit"}, + "800": {"__default__": "in_transit"}, + "804": {"__default__": "in_transit"}, + "810": {"__default__": "in_transit"}, + "815": {"__default__": "in_transit"}, + "8901": {"__default__": "in_transit"}, + "900": {"__default__": "in_transit"}, + "910": {"__default__": "in_transit"}, +} + + +def normalize_tracking_event_identifier(event_identifier): + raw = str(event_identifier or "").strip() + if raw.isdigit(): + return str(int(raw)) + return raw + + +def map_tracking_status(event_identifier, event_description=None): + normalized_event_id = normalize_tracking_event_identifier(event_identifier) + description_mapping = TRACKING_STATUS_MAPPING.get(normalized_event_id) + + if not description_mapping: + return "unknown" + + return description_mapping.get("__default__", "unknown") + + +def map_tracking_incident_reason(event_identifier): + normalized_event_id = normalize_tracking_event_identifier(event_identifier) + return next( + ( + reason.name + for reason in list(TrackingIncidentReason) + if normalized_event_id in reason.value + ), + None, + ) class TrackingIncidentReason(lib.Enum): diff --git a/modules/connectors/canadapost/tests/canadapost/test_tracking.py b/modules/connectors/canadapost/tests/canadapost/test_tracking.py index 72a154e3fb..9ccb228a09 100644 --- a/modules/connectors/canadapost/tests/canadapost/test_tracking.py +++ b/modules/connectors/canadapost/tests/canadapost/test_tracking.py @@ -3,6 +3,7 @@ from karrio.core.utils import DP from karrio.sdk import Tracking from karrio.core.models import TrackingRequest +import karrio.providers.canadapost.units as provider_units from .fixture import gateway @@ -46,6 +47,22 @@ def test_parse_tracking_response(self): ParsedTrackingResponse, ) + def test_parse_tracking_response_with_root_tracking_detail(self): + with patch("karrio.mappers.canadapost.proxy.lib.request") as mock: + mock.return_value = ( + TrackingResponseXml.replace("", "", 1) + .replace("", "", 1) + .strip() + ) + parsed_response = ( + Tracking.fetch(self.TrackingRequest).from_(gateway).parse() + ) + + self.assertListEqual( + DP.to_dict(parsed_response), + ParsedTrackingResponse, + ) + def test_tracking_unknown_response_parsing(self): with patch("karrio.mappers.canadapost.proxy.lib.request") as mock: mock.return_value = UnknownTrackingNumberResponse @@ -57,6 +74,28 @@ def test_tracking_unknown_response_parsing(self): ParsedUnknownTrackingNumberResponse, ) + def test_tracking_status_normalizes_padded_event_ids(self): + self.assertEqual(provider_units.map_tracking_status("0100"), "picked_up") + self.assertEqual(provider_units.map_tracking_status("0174"), "out_for_delivery") + self.assertEqual(provider_units.map_tracking_status("20"), "delivered") + + def test_tracking_status_does_not_use_description_overrides(self): + description = ( + "Tentative de renvoyer article à expéditeur. " + "Carte laissée indiquant où ramasser." + ) + self.assertEqual( + provider_units.map_tracking_status("1479", description), + "ready_for_pickup", + ) + self.assertEqual( + provider_units.map_tracking_status("1479", "Some other description"), + "ready_for_pickup", + ) + + def test_tracking_status_unknown_fallback(self): + self.assertEqual(provider_units.map_tracking_status("999999"), "unknown") + if __name__ == "__main__": unittest.main() @@ -81,7 +120,7 @@ def test_tracking_unknown_response_parsing(self): { "carrier_id": "canadapost", "carrier_name": "canadapost", - "delivered": False, + "delivered": True, "estimated_delivery": "2011-04-05", "events": [ { @@ -89,6 +128,7 @@ def test_tracking_unknown_response_parsing(self): "date": "2011-02-03", "description": "Signature image recorded for Online viewing", "location": "SAINTE-FOY, QC", + "status": "delivered", "time": "11:59 AM", "timestamp": "2011-02-03T11:59:00.000Z", }, @@ -97,6 +137,7 @@ def test_tracking_unknown_response_parsing(self): "date": "2011-02-03", "description": "Item out for delivery", "location": "SAINTE-FOY, QC", + "status": "out_for_delivery", "time": "08:27 AM", "timestamp": "2011-02-03T08:27:00.000Z", }, @@ -105,6 +146,7 @@ def test_tracking_unknown_response_parsing(self): "date": "2011-02-02", "description": "Item processed at postal facility", "location": "QUEBEC, QC", + "status": "picked_up", "time": "14:45 PM", "timestamp": "2011-02-02T14:45:00.000Z", }, @@ -113,6 +155,8 @@ def test_tracking_unknown_response_parsing(self): "date": "2011-02-02", "description": "Customer addressing error found; attempting to correct. Possible delay", "location": "QUEBEC, QC", + "reason": "consignee_incorrect_address", + "status": "on_hold", "time": "06:19 AM", "timestamp": "2011-02-02T06:19:00.000Z", }, @@ -130,6 +174,7 @@ def test_tracking_unknown_response_parsing(self): "date": "2011-02-01", "description": "Signature image recorded for Online viewing", "location": "QUEBEC, QC", + "status": "delivered", "time": "07:59 AM", "timestamp": "2011-02-01T07:59:00.000Z", }, @@ -141,7 +186,7 @@ def test_tracking_unknown_response_parsing(self): "shipment_service": "Expedited Parcels", "signed_by": "HETU", }, - "status": "in_transit", + "status": "delivered", "tracking_number": "7023210039414604", } ],