@@ -357,11 +357,59 @@ def shipping_options_initializer(
357357 return units.ShippingOptions(options, ShippingOption, items_filter=items_filter)
358358
359359class TrackingStatus(lib.Enum):
360+ " " " Maps carrier tracking status codes to normalized Karrio statuses." " "
360361 on_hold = [" ON_HOLD" ]
361362 delivered = [" DELIVERED" ]
362363 in_transit = [" IN_TRANSIT" ]
363364 delivery_failed = [" DELIVERY_FAILED" ]
364365 out_for_delivery = [" OUT_FOR_DELIVERY" ]
366+ pending = [" PENDING" , " CREATED" , " LABEL_PRINTED" ]
367+ picked_up = [" PICKED_UP" , " COLLECTED" ]
368+ delivery_delayed = [" DELAYED" , " RESCHEDULED" ]
369+ ready_for_pickup = [" READY_FOR_PICKUP" , " AT_LOCATION" ]
370+
371+
372+ class TrackingIncidentReason(lib.Enum):
373+ " " " Maps carrier exception codes to normalized incident reasons.
374+
375+ IMPORTANT: This enum is required for tracking implementations.
376+ It maps carrier-specific exception/status codes to standardized
377+ incident reasons for tracking events. The reason field helps
378+ identify why a delivery exception occurred.
379+
380+ Categories of reasons:
381+ - carrier_*: Issues caused by the carrier
382+ - consignee_*: Issues caused by the recipient
383+ - customs_*: Customs-related delays
384+ - weather_*: Weather/force majeure events
385+ " " "
386+ # Carrier-caused issues
387+ carrier_damaged_parcel = [" DAMAGED" , " DMG" ]
388+ carrier_sorting_error = [" MISROUTED" , " MSR" ]
389+ carrier_address_not_found = [" ADDRESS_NOT_FOUND" , " ANF" ]
390+ carrier_parcel_lost = [" LOST" , " LP" ]
391+ carrier_not_enough_time = [" LATE" , " NO_TIME" ]
392+ carrier_vehicle_issue = [" VEHICLE_BREAKDOWN" , " VB" ]
393+
394+ # Consignee-caused issues
395+ consignee_refused = [" REFUSED" , " RJ" ]
396+ consignee_business_closed = [" BUSINESS_CLOSED" , " BC" ]
397+ consignee_not_available = [" NOT_AVAILABLE" , " NA" ]
398+ consignee_not_home = [" NOT_HOME" , " NH" ]
399+ consignee_incorrect_address = [" WRONG_ADDRESS" , " IA" ]
400+ consignee_access_restricted = [" ACCESS_RESTRICTED" , " AR" ]
401+
402+ # Customs-related issues
403+ customs_delay = [" CUSTOMS_DELAY" , " CD" ]
404+ customs_documentation = [" CUSTOMS_DOCS" , " CM" ]
405+ customs_duties_unpaid = [" DUTIES_UNPAID" , " DU" ]
406+
407+ # Weather/Force majeure
408+ weather_delay = [" WEATHER" , " WE" ]
409+ natural_disaster = [" NATURAL_DISASTER" , " ND" ]
410+
411+ # Unknown
412+ unknown = []
365413` ` `
366414
367415# ## Step 10: Implement the API Proxy
@@ -674,6 +722,27 @@ import karrio.providers.[carrier_name].utils as provider_utils
674722import karrio.providers.[carrier_name].units as provider_units
675723import karrio.schemas.[carrier_name].tracking_response as [carrier_name]_res
676724
725+
726+ def _match_status(code: str) -> typing.Optional[str]:
727+ " " " Match code against TrackingStatus enum values." " "
728+ if not code:
729+ return None
730+ for status in list(provider_units.TrackingStatus):
731+ if code in status.value:
732+ return status.name
733+ return None
734+
735+
736+ def _match_reason(code: str) -> typing.Optional[str]:
737+ " " " Match code against TrackingIncidentReason enum values." " "
738+ if not code:
739+ return None
740+ for reason in list(provider_units.TrackingIncidentReason):
741+ if code in reason.value:
742+ return reason.name
743+ return None
744+
745+
677746def parse_tracking_response(
678747 _response: lib.Deserializable,
679748 settings: provider_utils.Settings,
@@ -689,21 +758,35 @@ def parse_tracking_response(
689758
690759 events = [
691760 models.TrackingEvent(
692- date=lib.to_date (event.date),
761+ date=lib.fdate (event.date, " %Y-%m-%d " ),
693762 description=event.description,
694763 location=event.location,
695- code=event.status,
696- time=lib.to_time(event.time),
764+ code=event.status_code,
765+ time=lib.flocaltime(event.time, " %H:%M:%S" ),
766+ # REQUIRED: timestamp in ISO 8601 format
767+ timestamp=lib.fiso_timestamp(
768+ lib.fdate(event.date, " %Y-%m-%d" ),
769+ lib.ftime(event.time, " %H:%M:%S" ),
770+ ),
771+ # REQUIRED: normalized status at event level
772+ status=_match_status(event.status_code),
773+ # Incident reason for exception events
774+ reason=_match_reason(event.status_code),
697775 )
698776 for event in (tracking.events or [])
699777 ]
700778
779+ # Determine overall status from latest event
780+ latest_event = events[0] if events else None
781+ status = latest_event.status or provider_units.TrackingStatus.in_transit.name
782+
701783 detail = models.TrackingDetails(
702784 carrier_id=settings.carrier_id,
703785 carrier_name=settings.carrier_name,
704786 tracking_number=tracking_number,
705787 events=events,
706- status=provider_units.TrackingStatus.map(tracking.status),
788+ status=status,
789+ delivered=status == " delivered" ,
707790 )
708791 tracking_details.append(detail)
709792
@@ -717,6 +800,152 @@ def tracking_request(
717800 return lib.Serializable(payload.tracking_numbers)
718801` ` `
719802
803+ ** IMPORTANT TrackingEvent Fields:**
804+
805+ | Field | Type | Required | Description |
806+ | -------| ------| ----------| -------------|
807+ | ` date` | str | Yes | Event date (e.g., " 2024-01-15" ) |
808+ | ` time` | str | Yes | Event time (e.g., " 14:30:00" ) |
809+ | ` description` | str | Yes | Human-readable event description |
810+ | ` code` | str | Yes | Carrier-specific status code |
811+ | ` location` | str | No | Event location |
812+ | ` timestamp` | str | ** Yes** | ISO 8601 timestamp (e.g., " 2024-01-15T14:30:00" ) |
813+ | ` status` | str | ** Yes** | Normalized status from ` TrackingStatus` enum |
814+ | ` reason` | str | No | Incident reason from ` TrackingIncidentReason` enum |
815+
816+ ** Usage of ` lib.fiso_timestamp` :**
817+ ` ` ` python
818+ # Combines date and time into ISO 8601 timestamp
819+ timestamp = lib.fiso_timestamp(
820+ lib.fdate(event.date, " %Y-%m-%d" ),
821+ lib.ftime(event.time, " %H:%M:%S" ),
822+ )
823+ # Result: "2024-01-15T14:30:00"
824+ ` ` `
825+
826+ # ### Multi-Piece/Multi-Package Shipment Support
827+
828+ ** CRITICAL** : Before implementing shipment creation, you MUST determine how the carrier API handles multi-package shipments. This affects the entire request/response structure.
829+
830+ # #### Step 1: Analyze Carrier API Documentation
831+
832+ Check the carrier API documentation to determine which pattern applies:
833+
834+ | Look For | Pattern | Implementation |
835+ | ----------| ---------| ----------------|
836+ | Single endpoint accepts ` packages[]` array | ** Bundled** | All packages in one request |
837+ | Response has ` PackageResults` or ` pieceResponses` | ** Bundled** | Parse individual package results |
838+ | Response has ` masterTrackingNumber` | ** Bundled** | Use master as primary tracking |
839+ | Must call endpoint once per package | ** Per-Package** | Create list of requests |
840+ | Each package gets separate label | ** Per-Package** | Use ` lib.to_multi_piece_shipment()` |
841+
842+ # #### Step 2: Implement Correct Pattern
843+
844+ ** Pattern A: Bundled Request (FedEx, UPS, DHL Express style)**
845+
846+ ` ` ` python
847+ def shipment_request(payload, settings):
848+ packages = lib.to_packages(payload.parcels)
849+
850+ # All packages in single request
851+ request = carrier_req.ShipmentRequestType(
852+ packages=[
853+ carrier_req.PackageType(
854+ weight=pkg.weight.KG,
855+ dimensions=carrier_req.DimensionsType(
856+ length=pkg.length.CM,
857+ width=pkg.width.CM,
858+ height=pkg.height.CM,
859+ ),
860+ )
861+ for pkg in packages
862+ ],
863+ # ... other fields
864+ )
865+ return lib.Serializable(request, lib.to_dict)
866+
867+ def parse_shipment_response(_response, settings):
868+ response = _response.deserialize ()
869+
870+ # Extract master tracking
871+ tracking_number = response.masterTrackingNumber
872+
873+ # Extract all package results
874+ packages = lib.failsafe(lambda: response.PackageResults) or []
875+ tracking_ids = [pkg.TrackingID for pkg in packages if pkg.TrackingID]
876+
877+ # Bundle all labels
878+ labels = [pkg.Label for pkg in packages if pkg.Label]
879+ label = lib.bundle_base64(labels, " PDF" ) if len(labels) > 1 else next(iter(labels), None)
880+
881+ return models.ShipmentDetails(
882+ tracking_number=tracking_number,
883+ docs=models.Documents(label=label),
884+ meta=dict(tracking_numbers=tracking_ids),
885+ ), messages
886+ ` ` `
887+
888+ ** Pattern B: Per-Package Request (Canada Post, USPS style)**
889+
890+ ` ` ` python
891+ def shipment_request(payload, settings):
892+ packages = lib.to_packages(payload.parcels)
893+
894+ # Create list of requests, one per package
895+ request = [
896+ carrier_req.ShipmentType(
897+ parcel=carrier_req.ParcelType(
898+ weight=pkg.weight.KG,
899+ dimensions=carrier_req.DimensionsType(...),
900+ ),
901+ # ... common fields for each package
902+ )
903+ for pkg in packages
904+ ]
905+ return lib.Serializable(request, _serialize_requests)
906+
907+ def parse_shipment_response(_response, settings):
908+ responses = _response.deserialize () # List of responses
909+ messages = error.parse_error_response(responses, settings)
910+
911+ # Extract details from each package response
912+ shipment_details = [
913+ (f" {idx}" , _extract_shipment(response, settings))
914+ for idx, response in enumerate(responses, start=1)
915+ if _is_valid_response(response)
916+ ]
917+
918+ # Use lib.to_multi_piece_shipment() to aggregate
919+ shipment = lib.to_multi_piece_shipment(shipment_details)
920+ return shipment, messages
921+ ` ` `
922+
923+ # #### Step 3: Ensure Proper Label Bundling
924+
925+ For multi-package shipments, always bundle labels:
926+
927+ ` ` ` python
928+ # For bundled pattern - bundle from package results
929+ labels = [pkg.Label for pkg in packages if pkg.Label]
930+ label = lib.bundle_base64(labels, label_type) if len(labels) > 1 else next(iter(labels), None)
931+
932+ # For per-package pattern - lib.to_multi_piece_shipment() handles bundling automatically
933+ ` ` `
934+
935+ # #### Step 4: Populate Meta Fields
936+
937+ Always include tracking numbers for all packages in meta:
938+
939+ ` ` ` python
940+ meta=dict(
941+ tracking_numbers=tracking_ids, # List of all package tracking numbers
942+ shipment_identifiers=shipment_ids, # List of all shipment IDs (if applicable)
943+ carrier_tracking_link=tracking_url, # Link for master/primary tracking
944+ )
945+ ` ` `
946+
947+ ---
948+
720949# ### Shipment Implementation
721950
722951** File** : ` karrio/providers/[carrier_name]/shipment/create.py`
0 commit comments