Skip to content

Commit 28e5d00

Browse files
authored
Merge pull request #930 from karrioapi/karrio-2026.1
[release] Karrio 2026.1
2 parents ee2d740 + a38788d commit 28e5d00

339 files changed

Lines changed: 87492 additions & 2474 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CARRIER_INTEGRATION_FAQ.md

Lines changed: 1457 additions & 0 deletions
Large diffs are not rendered by default.

CARRIER_INTEGRATION_GUIDE.md

Lines changed: 233 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,11 +357,59 @@ def shipping_options_initializer(
357357
return units.ShippingOptions(options, ShippingOption, items_filter=items_filter)
358358
359359
class 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
674722
import karrio.providers.[carrier_name].units as provider_units
675723
import 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+
677746
def 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`
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Carrier Integration Prompt Template
2+
3+
> **Purpose**: Concise prompt for AI agents implementing karrio carrier integrations.
4+
5+
---
6+
7+
## Prompt Template
8+
9+
```
10+
You are implementing a new carrier integration for Karrio.
11+
12+
## Required Reading (In Order)
13+
14+
Before writing any code, read these files thoroughly:
15+
16+
1. **CARRIER_INTEGRATION_GUIDE.md** - Step-by-step integration process
17+
2. **CARRIER_INTEGRATION_FAQ.md** - Common pitfalls and best practices
18+
3. **AGENTS.md** - Coding style and project conventions
19+
20+
## Task
21+
22+
Implement a carrier integration for {CARRIER_NAME}.
23+
24+
- Carrier slug: {carrier_slug}
25+
- Display name: {Display Name}
26+
- API type: [JSON/XML]
27+
- Features: [rating, shipping, tracking, pickup]
28+
- API docs: {documentation_url}
29+
30+
## Process
31+
32+
1. Bootstrap using CLI (CARRIER_INTEGRATION_GUIDE.md Phase 1)
33+
2. Generate schemas from API samples (Phase 2)
34+
3. Implement features following patterns in FAQ sections 7-8
35+
4. Write tests following the 4-test pattern (Phase 4)
36+
5. Verify against FAQ best practices checklist
37+
6. Run all success criteria commands (Phase 6)
38+
39+
## Reference Carriers
40+
41+
Study these for implementation patterns:
42+
- DHL Express: `modules/connectors/dhl_express/` (single tree instantiation)
43+
- Canada Post: `modules/connectors/canadapost/` (services inline)
44+
- UPS: `modules/connectors/ups/` (options handling)
45+
```
46+
47+
---
48+
49+
## Usage
50+
51+
### New Integration
52+
```
53+
[Paste template above with carrier details filled in]
54+
```
55+
56+
### Adding Features
57+
```
58+
Add {FEATURE} to the existing {CARRIER_NAME} integration.
59+
Follow CARRIER_INTEGRATION_GUIDE.md Phases 3-6.
60+
Reference: modules/connectors/{reference_carrier}/
61+
```
62+
63+
### Bug Fixes
64+
```
65+
Fix {ISSUE} in {CARRIER_NAME} integration.
66+
Review CARRIER_INTEGRATION_FAQ.md for relevant best practices.
67+
```
68+
69+
---
70+
71+
## See Also
72+
73+
- [CARRIER_INTEGRATION_GUIDE.md](./CARRIER_INTEGRATION_GUIDE.md)
74+
- [CARRIER_INTEGRATION_FAQ.md](./CARRIER_INTEGRATION_FAQ.md)
75+
- [AGENTS.md](./AGENTS.md)

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
# Karrio 2026.1
2+
3+
## Changes
4+
5+
### Feat
6+
7+
- feat(hermes): add Hermes Germany carrier integration with shipping, pickup, and rating support
8+
- feat(gls): add GLS Group carrier integration with OAuth2 authentication and shipment/tracking
9+
- feat(dpd_meta): add DPD META-API carrier integration with Bearer token authentication and caching
10+
- feat(postat): add Austrian Post (PostAT) carrier integration via Post-Labelcenter SOAP API
11+
- feat(parcelone): add ParcelOne multi-carrier hub integration with JSON REST API
12+
- feat(dhl_parcel_de): implement pickup support
13+
- feat: apply ratesheet GraphQL enhancements and the editor with shared zones and surcharges
14+
- feat: implement find helper for status and reason mapping retrieval
15+
- feat: introduce carrier integration FAQ and improve multi-piece shipment abstraction
16+
17+
### Chore
18+
19+
- chore: move chronopost to karrio core maintained connectors
20+
- refactor: standardize carrier authentication to use Proxy.authenticate() method
21+
- refactor: review and cleanup new integrations to match coding standard
22+
23+
---
24+
125
# Karrio 2025.5.7
226

327
## Changes

0 commit comments

Comments
 (0)