Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 11 additions & 18 deletions modules/connectors/purolator/karrio/providers/purolator/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,13 @@ def _extract_details(
node: lib.Element, settings: provider_utils.Settings
) -> models.TrackingDetails:
track = lib.to_object(purolator.TrackingInformation, node)
delivered = any(scan.ScanType == "Delivery" for scan in track.Scans.Scan)
last_event = track.Scans.Scan[0]
status = next(
(
status.name
for status in list(provider_units.TrackingStatus)
if getattr(last_event, "ScanType", None) in status.value
),
provider_units.TrackingStatus.in_transit.name,
scans = list(getattr(getattr(track, "Scans", None), "Scan", []) or [])
last_event = scans[0] if scans else None
status = provider_units.map_tracking_status(
getattr(last_event, "ScanType", None),
getattr(last_event, "Description", None),
)
delivered = status == provider_units.TrackingStatus.delivered.name

return models.TrackingDetails(
carrier_name=settings.carrier_name,
Expand All @@ -45,19 +42,15 @@ def _extract_details(
date=lib.fdate(scan.ScanDate),
time=lib.flocaltime(scan.ScanTime, "%H%M%S"),
description=scan.Description,
location=scan.Depot.Name,
location=getattr(getattr(scan, "Depot", None), "Name", None),
code=scan.ScanType,
timestamp=lib.fiso_timestamp(
lib.fdate(scan.ScanDate),
lib.ftime(scan.ScanTime, "%H%M%S"),
),
status=next(
(
s.name
for s in list(provider_units.TrackingStatus)
if getattr(scan, "ScanType", None) in s.value
),
None,
status=provider_units.map_tracking_status(
getattr(scan, "ScanType", None),
getattr(scan, "Description", None),
),
reason=next(
(
Expand All @@ -68,7 +61,7 @@ def _extract_details(
None,
),
)
for scan in track.Scans.Scan
for scan in scans
],
info=models.TrackingInfo(
carrier_tracking_link=settings.tracking_url.format(track.PIN.Value)
Expand Down
205 changes: 202 additions & 3 deletions modules/connectors/purolator/karrio/providers/purolator/units.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import csv
import pathlib
import re
import typing
import unicodedata
import karrio.lib as lib
import karrio.core.units as units
import karrio.core.models as models
Expand Down Expand Up @@ -227,10 +229,207 @@ def shipping_services_initializer(


class TrackingStatus(lib.Enum):
in_transit = [""]
delivered = ["Delivery"]
delivery_failed = ["Undeliverable"]
# Keep explicit status names we emit from `map_tracking_status`.
pending = ["__pending__"]
picked_up = ["ProofOfPickUp"]
in_transit = ["Other"]
out_for_delivery = ["OnDelivery"]
delivered = ["Delivery"]
on_hold = ["Undeliverable"]
ready_for_pickup = ["__ready_for_pickup__"]
return_to_sender = ["__return_to_sender__"]
delivery_delayed = ["__delivery_delayed__"]
delivery_failed = ["__delivery_failed__"]
unknown = [""]


# Purolator ScanType values are too coarse by themselves, especially
# `Undeliverable`. Real tracking payloads encode the useful status in
# Description, so exact audited descriptions are mapped first and keyword
# fallback covers wording drift.
PUROLATOR_TRACKING_STATUS_MAPPING: dict[str, dict[str, str]] = {
"Other": {
"__default__": TrackingStatus.in_transit.name,
"Shipper created a label": TrackingStatus.pending.name,
"Shipment created - interim manifest received": TrackingStatus.pending.name,
"Shipment created - final manifest received": TrackingStatus.pending.name,
"New tracking number assigned": TrackingStatus.pending.name,
"Label information electronically submitted": TrackingStatus.pending.name,
},
"OnDelivery": {
"__default__": TrackingStatus.out_for_delivery.name,
"On vehicle for delivery": TrackingStatus.out_for_delivery.name,
},
"Delivery": {
"__default__": TrackingStatus.delivered.name,
"Shipment delivered": TrackingStatus.delivered.name,
"Delivered to Customer by Locker": TrackingStatus.delivered.name,
"Package removed from Locker": TrackingStatus.delivered.name,
# Seen in production under ScanType=Delivery and not a final delivery.
"Transferring to Shipping Centre - please wait for further instructions": TrackingStatus.in_transit.name,
},
"ProofOfPickUp": {
"__default__": TrackingStatus.picked_up.name,
"Picked up by Purolator at": TrackingStatus.picked_up.name,
"Received by Purolator for processing at": TrackingStatus.picked_up.name,
},
"Undeliverable": {
"__default__": TrackingStatus.on_hold.name,
"Shipment created - interim manifest received": TrackingStatus.pending.name,
"Shipment created - final manifest received": TrackingStatus.pending.name,
"Shipment created": TrackingStatus.pending.name,
"Shipper created a label": TrackingStatus.pending.name,
"Arrived at sort facility": TrackingStatus.in_transit.name,
"Departed sort facility": TrackingStatus.in_transit.name,
"Shipment in transit": TrackingStatus.in_transit.name,
"Shipment redirected": TrackingStatus.in_transit.name,
"Resolution complete - shipment redirected": TrackingStatus.in_transit.name,
"Available for pickup for 5 business days from arrival date at the counter": TrackingStatus.ready_for_pickup.name,
"Item Held for Pickup at Locker": TrackingStatus.ready_for_pickup.name,
"Item available for receiver to pick up at post office": TrackingStatus.ready_for_pickup.name,
"Receiver advised they will pick up shipment": TrackingStatus.ready_for_pickup.name,
"Shipment available for pickup. Unable to contact customer": TrackingStatus.ready_for_pickup.name,
"Shipment available for pickup. Unable to contact customer.": TrackingStatus.ready_for_pickup.name,
"Receiver contacted. Shipment available for pickup": TrackingStatus.ready_for_pickup.name,
"Receiver contacted, no answer. Shipment available for pickup": TrackingStatus.ready_for_pickup.name,
"Shipper contacted. Shipment available for pickup": TrackingStatus.ready_for_pickup.name,
"Shipment unclaimed - to be returned to sender": TrackingStatus.return_to_sender.name,
"Shipment undeliverable - Returned to sender": TrackingStatus.return_to_sender.name,
"Unable to deliver - item returned to sender": TrackingStatus.return_to_sender.name,
"Unable to deliver - item returned to shipper": TrackingStatus.return_to_sender.name,
"Returned to sender. Shipment no longer available for pickup": TrackingStatus.return_to_sender.name,
},
}


UNDELIVERABLE_KEYWORD_RULES: list[tuple[tuple[str, ...], str]] = [
(
(
"returned to sender",
"returned to shipper",
"to be returned to sender",
"returned to the shipper",
"retourne a l'expediteur",
"retour a l'expediteur",
"renvoye a l'expediteur",
"retourne a l expediteur",
"retour a l expediteur",
"renvoye a l expediteur",
),
TrackingStatus.return_to_sender.name,
),
(
(
"available for pickup",
"held for pickup",
"pickup location",
"disponible pour le ramassage",
"disponible pour ramassage",
"point de ramassage",
"point de cueillette",
"ramassage",
"ramasser",
),
TrackingStatus.ready_for_pickup.name,
),
(
(
"delayed",
"delay",
"rescheduled",
"redelivery",
"new delivery date",
"missed connection",
"mechanical",
"weather",
"road closure",
"natural disaster",
"sorting error",
"late tender",
"special handling",
"hold period extended",
"re-attempt",
"rail delay",
"ferry delay",
"service disruption",
"retard",
"retarde",
"retardee",
"retardes",
"retardees",
"reporte",
"reportee",
"reportes",
"reportees",
"replanifie",
"replanifiee",
"replanifies",
"replanifiees",
"meteo",
"intemperies",
"fermeture de route",
"catastrophe naturelle",
"perturbation de service",
),
TrackingStatus.delivery_delayed.name,
),
]


def normalize_tracking_description(description: typing.Optional[str]) -> str:
normalized = re.sub(r"\s+", " ", str(description or "").strip().lower())
normalized = normalized.replace("’", "'")
normalized = "".join(
c
for c in unicodedata.normalize("NFKD", normalized)
if not unicodedata.combining(c)
)
return normalized.rstrip(".")


def _normalize_tracking_status_mapping(
raw_mapping: dict[str, dict[str, str]],
) -> dict[str, dict[str, str]]:
normalized_mapping: dict[str, dict[str, str]] = {}
for event_code, description_mapping in raw_mapping.items():
normalized_mapping[event_code] = {}
for description, mapped_status in description_mapping.items():
if description == "__default__":
normalized_mapping[event_code]["__default__"] = mapped_status
continue

normalized_mapping[event_code][
normalize_tracking_description(description)
] = mapped_status

return normalized_mapping


NORMALIZED_PUROLATOR_TRACKING_STATUS_MAPPING = _normalize_tracking_status_mapping(
PUROLATOR_TRACKING_STATUS_MAPPING
)


def map_tracking_status(
event_code: typing.Optional[str],
event_description: typing.Optional[str],
) -> str:
code = str(event_code or "").strip()
normalized_description = normalize_tracking_description(event_description)
mapped_descriptions = NORMALIZED_PUROLATOR_TRACKING_STATUS_MAPPING.get(code)

if mapped_descriptions is None:
return TrackingStatus.unknown.name

if normalized_description in mapped_descriptions:
return mapped_descriptions[normalized_description]

if code == "Undeliverable":
for needles, status_id in UNDELIVERABLE_KEYWORD_RULES:
if any(needle in normalized_description for needle in needles):
return status_id

return mapped_descriptions.get("__default__", TrackingStatus.unknown.name)


class TrackingIncidentReason(lib.Enum):
Expand Down
83 changes: 83 additions & 0 deletions modules/connectors/purolator/tests/purolator/test_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from karrio.core.utils import DP
from karrio.core.models import TrackingRequest
from karrio.sdk import Tracking
import karrio.providers.purolator.units as provider_units
from .fixture import gateway


Expand Down Expand Up @@ -35,6 +36,86 @@ def test_tracking_response_parsing(self):
)
self.assertListEqual(DP.to_dict(parsed_response), PARSED_TRACKING_RESPONSE)

def test_map_tracking_status_with_description_overrides(self):
self.assertEqual(
provider_units.map_tracking_status(
"Delivery",
"Transferring to Shipping Centre - please wait for further instructions",
),
provider_units.TrackingStatus.in_transit.name,
)
self.assertEqual(
provider_units.map_tracking_status(
"Other",
"Shipper created a label",
),
provider_units.TrackingStatus.pending.name,
)
self.assertEqual(
provider_units.map_tracking_status(
"ProofOfPickUp",
"Picked up by Purolator at",
),
provider_units.TrackingStatus.picked_up.name,
)

def test_map_tracking_status_with_undeliverable_keyword_fallback(self):
self.assertEqual(
provider_units.map_tracking_status(
"Undeliverable",
"Shipment undeliverable - Returned to sender",
),
provider_units.TrackingStatus.return_to_sender.name,
)
self.assertEqual(
provider_units.map_tracking_status(
"Undeliverable",
"Delivery delayed due to weather event",
),
provider_units.TrackingStatus.delivery_delayed.name,
)
self.assertEqual(
provider_units.map_tracking_status(
"Undeliverable",
"Unexpected wording for this event",
),
provider_units.TrackingStatus.on_hold.name,
)
self.assertEqual(
provider_units.map_tracking_status(
"Undeliverable",
"Shipment available for pickup. Unable to contact customer.",
),
provider_units.TrackingStatus.ready_for_pickup.name,
)
self.assertEqual(
provider_units.map_tracking_status(None, None),
provider_units.TrackingStatus.unknown.name,
)

def test_map_tracking_status_with_french_descriptions(self):
self.assertEqual(
provider_units.map_tracking_status(
"Undeliverable",
"Envoi disponible pour le ramassage au point de service",
),
provider_units.TrackingStatus.ready_for_pickup.name,
)
self.assertEqual(
provider_units.map_tracking_status(
"Undeliverable",
"Envoi retourne a l'expediteur",
),
provider_units.TrackingStatus.return_to_sender.name,
)
self.assertEqual(
provider_units.map_tracking_status(
"Undeliverable",
"Livraison retardee en raison des conditions meteo",
),
provider_units.TrackingStatus.delivery_delayed.name,
)


if __name__ == "__main__":
unittest.main()
Expand All @@ -53,6 +134,7 @@ def test_tracking_response_parsing(self):
"date": "2004-01-13",
"description": "New Tracking Number Assigned -",
"location": "MONTREAL SORT CTR/CTR TRIE, PQ",
"status": "in_transit",
"time": "17:23 PM",
"timestamp": "2004-01-13T17:23:00.000Z",
},
Expand All @@ -61,6 +143,7 @@ def test_tracking_response_parsing(self):
"date": "2004-01-13",
"description": "New Tracking Number Assigned -",
"location": "MONTREAL SORT CTR/CTR TRIE, PQ",
"status": "in_transit",
"time": "17:23 PM",
"timestamp": "2004-01-13T17:23:00.000Z",
},
Expand Down