Skip to content

Commit 5877deb

Browse files
committed
fix(purolator): normalize tracking status by description
1 parent 8211f50 commit 5877deb

3 files changed

Lines changed: 296 additions & 21 deletions

File tree

modules/connectors/purolator/karrio/providers/purolator/tracking.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,13 @@ def _extract_details(
2323
node: lib.Element, settings: provider_utils.Settings
2424
) -> models.TrackingDetails:
2525
track = lib.to_object(purolator.TrackingInformation, node)
26-
delivered = any(scan.ScanType == "Delivery" for scan in track.Scans.Scan)
27-
last_event = track.Scans.Scan[0]
28-
status = next(
29-
(
30-
status.name
31-
for status in list(provider_units.TrackingStatus)
32-
if getattr(last_event, "ScanType", None) in status.value
33-
),
34-
provider_units.TrackingStatus.in_transit.name,
26+
scans = list(getattr(getattr(track, "Scans", None), "Scan", []) or [])
27+
last_event = scans[0] if scans else None
28+
status = provider_units.map_tracking_status(
29+
getattr(last_event, "ScanType", None),
30+
getattr(last_event, "Description", None),
3531
)
32+
delivered = status == provider_units.TrackingStatus.delivered.name
3633

3734
return models.TrackingDetails(
3835
carrier_name=settings.carrier_name,
@@ -45,19 +42,15 @@ def _extract_details(
4542
date=lib.fdate(scan.ScanDate),
4643
time=lib.flocaltime(scan.ScanTime, "%H%M%S"),
4744
description=scan.Description,
48-
location=scan.Depot.Name,
45+
location=getattr(getattr(scan, "Depot", None), "Name", None),
4946
code=scan.ScanType,
5047
timestamp=lib.fiso_timestamp(
5148
lib.fdate(scan.ScanDate),
5249
lib.ftime(scan.ScanTime, "%H%M%S"),
5350
),
54-
status=next(
55-
(
56-
s.name
57-
for s in list(provider_units.TrackingStatus)
58-
if getattr(scan, "ScanType", None) in s.value
59-
),
60-
None,
51+
status=provider_units.map_tracking_status(
52+
getattr(scan, "ScanType", None),
53+
getattr(scan, "Description", None),
6154
),
6255
reason=next(
6356
(
@@ -68,7 +61,7 @@ def _extract_details(
6861
None,
6962
),
7063
)
71-
for scan in track.Scans.Scan
64+
for scan in scans
7265
],
7366
info=models.TrackingInfo(
7467
carrier_tracking_link=settings.tracking_url.format(track.PIN.Value)

modules/connectors/purolator/karrio/providers/purolator/units.py

Lines changed: 202 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import csv
22
import pathlib
3+
import re
34
import typing
5+
import unicodedata
46
import karrio.lib as lib
57
import karrio.core.units as units
68
import karrio.core.models as models
@@ -227,10 +229,207 @@ def shipping_services_initializer(
227229

228230

229231
class TrackingStatus(lib.Enum):
230-
in_transit = [""]
231-
delivered = ["Delivery"]
232-
delivery_failed = ["Undeliverable"]
232+
# Keep explicit status names we emit from `map_tracking_status`.
233+
pending = ["__pending__"]
234+
picked_up = ["ProofOfPickUp"]
235+
in_transit = ["Other"]
233236
out_for_delivery = ["OnDelivery"]
237+
delivered = ["Delivery"]
238+
on_hold = ["Undeliverable"]
239+
ready_for_pickup = ["__ready_for_pickup__"]
240+
return_to_sender = ["__return_to_sender__"]
241+
delivery_delayed = ["__delivery_delayed__"]
242+
delivery_failed = ["__delivery_failed__"]
243+
unknown = [""]
244+
245+
246+
# Purolator ScanType values are too coarse by themselves, especially
247+
# `Undeliverable`. Real tracking payloads encode the useful status in
248+
# Description, so exact audited descriptions are mapped first and keyword
249+
# fallback covers wording drift.
250+
PUROLATOR_TRACKING_STATUS_MAPPING: dict[str, dict[str, str]] = {
251+
"Other": {
252+
"__default__": TrackingStatus.in_transit.name,
253+
"Shipper created a label": TrackingStatus.pending.name,
254+
"Shipment created - interim manifest received": TrackingStatus.pending.name,
255+
"Shipment created - final manifest received": TrackingStatus.pending.name,
256+
"New tracking number assigned": TrackingStatus.pending.name,
257+
"Label information electronically submitted": TrackingStatus.pending.name,
258+
},
259+
"OnDelivery": {
260+
"__default__": TrackingStatus.out_for_delivery.name,
261+
"On vehicle for delivery": TrackingStatus.out_for_delivery.name,
262+
},
263+
"Delivery": {
264+
"__default__": TrackingStatus.delivered.name,
265+
"Shipment delivered": TrackingStatus.delivered.name,
266+
"Delivered to Customer by Locker": TrackingStatus.delivered.name,
267+
"Package removed from Locker": TrackingStatus.delivered.name,
268+
# Seen in production under ScanType=Delivery and not a final delivery.
269+
"Transferring to Shipping Centre - please wait for further instructions": TrackingStatus.in_transit.name,
270+
},
271+
"ProofOfPickUp": {
272+
"__default__": TrackingStatus.picked_up.name,
273+
"Picked up by Purolator at": TrackingStatus.picked_up.name,
274+
"Received by Purolator for processing at": TrackingStatus.picked_up.name,
275+
},
276+
"Undeliverable": {
277+
"__default__": TrackingStatus.on_hold.name,
278+
"Shipment created - interim manifest received": TrackingStatus.pending.name,
279+
"Shipment created - final manifest received": TrackingStatus.pending.name,
280+
"Shipment created": TrackingStatus.pending.name,
281+
"Shipper created a label": TrackingStatus.pending.name,
282+
"Arrived at sort facility": TrackingStatus.in_transit.name,
283+
"Departed sort facility": TrackingStatus.in_transit.name,
284+
"Shipment in transit": TrackingStatus.in_transit.name,
285+
"Shipment redirected": TrackingStatus.in_transit.name,
286+
"Resolution complete - shipment redirected": TrackingStatus.in_transit.name,
287+
"Available for pickup for 5 business days from arrival date at the counter": TrackingStatus.ready_for_pickup.name,
288+
"Item Held for Pickup at Locker": TrackingStatus.ready_for_pickup.name,
289+
"Item available for receiver to pick up at post office": TrackingStatus.ready_for_pickup.name,
290+
"Receiver advised they will pick up shipment": TrackingStatus.ready_for_pickup.name,
291+
"Shipment available for pickup. Unable to contact customer": TrackingStatus.ready_for_pickup.name,
292+
"Shipment available for pickup. Unable to contact customer.": TrackingStatus.ready_for_pickup.name,
293+
"Receiver contacted. Shipment available for pickup": TrackingStatus.ready_for_pickup.name,
294+
"Receiver contacted, no answer. Shipment available for pickup": TrackingStatus.ready_for_pickup.name,
295+
"Shipper contacted. Shipment available for pickup": TrackingStatus.ready_for_pickup.name,
296+
"Shipment unclaimed - to be returned to sender": TrackingStatus.return_to_sender.name,
297+
"Shipment undeliverable - Returned to sender": TrackingStatus.return_to_sender.name,
298+
"Unable to deliver - item returned to sender": TrackingStatus.return_to_sender.name,
299+
"Unable to deliver - item returned to shipper": TrackingStatus.return_to_sender.name,
300+
"Returned to sender. Shipment no longer available for pickup": TrackingStatus.return_to_sender.name,
301+
},
302+
}
303+
304+
305+
UNDELIVERABLE_KEYWORD_RULES: list[tuple[tuple[str, ...], str]] = [
306+
(
307+
(
308+
"returned to sender",
309+
"returned to shipper",
310+
"to be returned to sender",
311+
"returned to the shipper",
312+
"retourne a l'expediteur",
313+
"retour a l'expediteur",
314+
"renvoye a l'expediteur",
315+
"retourne a l expediteur",
316+
"retour a l expediteur",
317+
"renvoye a l expediteur",
318+
),
319+
TrackingStatus.return_to_sender.name,
320+
),
321+
(
322+
(
323+
"available for pickup",
324+
"held for pickup",
325+
"pickup location",
326+
"disponible pour le ramassage",
327+
"disponible pour ramassage",
328+
"point de ramassage",
329+
"point de cueillette",
330+
"ramassage",
331+
"ramasser",
332+
),
333+
TrackingStatus.ready_for_pickup.name,
334+
),
335+
(
336+
(
337+
"delayed",
338+
"delay",
339+
"rescheduled",
340+
"redelivery",
341+
"new delivery date",
342+
"missed connection",
343+
"mechanical",
344+
"weather",
345+
"road closure",
346+
"natural disaster",
347+
"sorting error",
348+
"late tender",
349+
"special handling",
350+
"hold period extended",
351+
"re-attempt",
352+
"rail delay",
353+
"ferry delay",
354+
"service disruption",
355+
"retard",
356+
"retarde",
357+
"retardee",
358+
"retardes",
359+
"retardees",
360+
"reporte",
361+
"reportee",
362+
"reportes",
363+
"reportees",
364+
"replanifie",
365+
"replanifiee",
366+
"replanifies",
367+
"replanifiees",
368+
"meteo",
369+
"intemperies",
370+
"fermeture de route",
371+
"catastrophe naturelle",
372+
"perturbation de service",
373+
),
374+
TrackingStatus.delivery_delayed.name,
375+
),
376+
]
377+
378+
379+
def normalize_tracking_description(description: typing.Optional[str]) -> str:
380+
normalized = re.sub(r"\s+", " ", str(description or "").strip().lower())
381+
normalized = normalized.replace("’", "'")
382+
normalized = "".join(
383+
c
384+
for c in unicodedata.normalize("NFKD", normalized)
385+
if not unicodedata.combining(c)
386+
)
387+
return normalized.rstrip(".")
388+
389+
390+
def _normalize_tracking_status_mapping(
391+
raw_mapping: dict[str, dict[str, str]],
392+
) -> dict[str, dict[str, str]]:
393+
normalized_mapping: dict[str, dict[str, str]] = {}
394+
for event_code, description_mapping in raw_mapping.items():
395+
normalized_mapping[event_code] = {}
396+
for description, mapped_status in description_mapping.items():
397+
if description == "__default__":
398+
normalized_mapping[event_code]["__default__"] = mapped_status
399+
continue
400+
401+
normalized_mapping[event_code][
402+
normalize_tracking_description(description)
403+
] = mapped_status
404+
405+
return normalized_mapping
406+
407+
408+
NORMALIZED_PUROLATOR_TRACKING_STATUS_MAPPING = _normalize_tracking_status_mapping(
409+
PUROLATOR_TRACKING_STATUS_MAPPING
410+
)
411+
412+
413+
def map_tracking_status(
414+
event_code: typing.Optional[str],
415+
event_description: typing.Optional[str],
416+
) -> str:
417+
code = str(event_code or "").strip()
418+
normalized_description = normalize_tracking_description(event_description)
419+
mapped_descriptions = NORMALIZED_PUROLATOR_TRACKING_STATUS_MAPPING.get(code)
420+
421+
if mapped_descriptions is None:
422+
return TrackingStatus.unknown.name
423+
424+
if normalized_description in mapped_descriptions:
425+
return mapped_descriptions[normalized_description]
426+
427+
if code == "Undeliverable":
428+
for needles, status_id in UNDELIVERABLE_KEYWORD_RULES:
429+
if any(needle in normalized_description for needle in needles):
430+
return status_id
431+
432+
return mapped_descriptions.get("__default__", TrackingStatus.unknown.name)
234433

235434

236435
class TrackingIncidentReason(lib.Enum):

modules/connectors/purolator/tests/purolator/test_tracking.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from karrio.core.utils import DP
44
from karrio.core.models import TrackingRequest
55
from karrio.sdk import Tracking
6+
import karrio.providers.purolator.units as provider_units
67
from .fixture import gateway
78

89

@@ -35,6 +36,86 @@ def test_tracking_response_parsing(self):
3536
)
3637
self.assertListEqual(DP.to_dict(parsed_response), PARSED_TRACKING_RESPONSE)
3738

39+
def test_map_tracking_status_with_description_overrides(self):
40+
self.assertEqual(
41+
provider_units.map_tracking_status(
42+
"Delivery",
43+
"Transferring to Shipping Centre - please wait for further instructions",
44+
),
45+
provider_units.TrackingStatus.in_transit.name,
46+
)
47+
self.assertEqual(
48+
provider_units.map_tracking_status(
49+
"Other",
50+
"Shipper created a label",
51+
),
52+
provider_units.TrackingStatus.pending.name,
53+
)
54+
self.assertEqual(
55+
provider_units.map_tracking_status(
56+
"ProofOfPickUp",
57+
"Picked up by Purolator at",
58+
),
59+
provider_units.TrackingStatus.picked_up.name,
60+
)
61+
62+
def test_map_tracking_status_with_undeliverable_keyword_fallback(self):
63+
self.assertEqual(
64+
provider_units.map_tracking_status(
65+
"Undeliverable",
66+
"Shipment undeliverable - Returned to sender",
67+
),
68+
provider_units.TrackingStatus.return_to_sender.name,
69+
)
70+
self.assertEqual(
71+
provider_units.map_tracking_status(
72+
"Undeliverable",
73+
"Delivery delayed due to weather event",
74+
),
75+
provider_units.TrackingStatus.delivery_delayed.name,
76+
)
77+
self.assertEqual(
78+
provider_units.map_tracking_status(
79+
"Undeliverable",
80+
"Unexpected wording for this event",
81+
),
82+
provider_units.TrackingStatus.on_hold.name,
83+
)
84+
self.assertEqual(
85+
provider_units.map_tracking_status(
86+
"Undeliverable",
87+
"Shipment available for pickup. Unable to contact customer.",
88+
),
89+
provider_units.TrackingStatus.ready_for_pickup.name,
90+
)
91+
self.assertEqual(
92+
provider_units.map_tracking_status(None, None),
93+
provider_units.TrackingStatus.unknown.name,
94+
)
95+
96+
def test_map_tracking_status_with_french_descriptions(self):
97+
self.assertEqual(
98+
provider_units.map_tracking_status(
99+
"Undeliverable",
100+
"Envoi disponible pour le ramassage au point de service",
101+
),
102+
provider_units.TrackingStatus.ready_for_pickup.name,
103+
)
104+
self.assertEqual(
105+
provider_units.map_tracking_status(
106+
"Undeliverable",
107+
"Envoi retourne a l'expediteur",
108+
),
109+
provider_units.TrackingStatus.return_to_sender.name,
110+
)
111+
self.assertEqual(
112+
provider_units.map_tracking_status(
113+
"Undeliverable",
114+
"Livraison retardee en raison des conditions meteo",
115+
),
116+
provider_units.TrackingStatus.delivery_delayed.name,
117+
)
118+
38119

39120
if __name__ == "__main__":
40121
unittest.main()
@@ -53,6 +134,7 @@ def test_tracking_response_parsing(self):
53134
"date": "2004-01-13",
54135
"description": "New Tracking Number Assigned -",
55136
"location": "MONTREAL SORT CTR/CTR TRIE, PQ",
137+
"status": "in_transit",
56138
"time": "17:23 PM",
57139
"timestamp": "2004-01-13T17:23:00.000Z",
58140
},
@@ -61,6 +143,7 @@ def test_tracking_response_parsing(self):
61143
"date": "2004-01-13",
62144
"description": "New Tracking Number Assigned -",
63145
"location": "MONTREAL SORT CTR/CTR TRIE, PQ",
146+
"status": "in_transit",
64147
"time": "17:23 PM",
65148
"timestamp": "2004-01-13T17:23:00.000Z",
66149
},

0 commit comments

Comments
 (0)