|
1 | 1 | import csv |
2 | 2 | import pathlib |
| 3 | +import re |
3 | 4 | import typing |
| 5 | +import unicodedata |
4 | 6 | import karrio.lib as lib |
5 | 7 | import karrio.core.units as units |
6 | 8 | import karrio.core.models as models |
@@ -227,10 +229,207 @@ def shipping_services_initializer( |
227 | 229 |
|
228 | 230 |
|
229 | 231 | 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"] |
233 | 236 | 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) |
234 | 433 |
|
235 | 434 |
|
236 | 435 | class TrackingIncidentReason(lib.Enum): |
|
0 commit comments