karrio-server-manager 2026.1__py3-none-any.whl → 2026.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. karrio/server/manager/migrations/0070_add_meta_and_product_fields.py +98 -0
  2. karrio/server/manager/migrations/0071_product_proxy.py +25 -0
  3. karrio/server/manager/migrations/0072_populate_json_fields.py +267 -0
  4. karrio/server/manager/migrations/0073_make_shipment_fk_nullable.py +36 -0
  5. karrio/server/manager/migrations/0074_clean_model_refactoring.py +207 -0
  6. karrio/server/manager/migrations/0075_populate_template_meta.py +69 -0
  7. karrio/server/manager/migrations/0076_remove_customs_model.py +66 -0
  8. karrio/server/manager/migrations/0077_add_carrier_snapshot_fields.py +83 -0
  9. karrio/server/manager/migrations/0078_populate_carrier_snapshots.py +112 -0
  10. karrio/server/manager/migrations/0079_remove_carrier_fk_fields.py +56 -0
  11. karrio/server/manager/migrations/0080_add_carrier_json_indexes.py +137 -0
  12. karrio/server/manager/migrations/0081_cleanup.py +62 -0
  13. karrio/server/manager/migrations/0082_shipment_fees.py +26 -0
  14. karrio/server/manager/models.py +421 -321
  15. karrio/server/manager/serializers/__init__.py +5 -4
  16. karrio/server/manager/serializers/address.py +8 -2
  17. karrio/server/manager/serializers/commodity.py +11 -4
  18. karrio/server/manager/serializers/document.py +29 -15
  19. karrio/server/manager/serializers/manifest.py +6 -3
  20. karrio/server/manager/serializers/parcel.py +5 -2
  21. karrio/server/manager/serializers/pickup.py +194 -67
  22. karrio/server/manager/serializers/shipment.py +232 -152
  23. karrio/server/manager/serializers/tracking.py +53 -12
  24. karrio/server/manager/tests/__init__.py +0 -1
  25. karrio/server/manager/tests/test_addresses.py +53 -0
  26. karrio/server/manager/tests/test_parcels.py +50 -0
  27. karrio/server/manager/tests/test_pickups.py +286 -50
  28. karrio/server/manager/tests/test_products.py +597 -0
  29. karrio/server/manager/tests/test_shipments.py +237 -92
  30. karrio/server/manager/tests/test_trackers.py +65 -1
  31. karrio/server/manager/views/__init__.py +1 -1
  32. karrio/server/manager/views/addresses.py +38 -2
  33. karrio/server/manager/views/documents.py +1 -1
  34. karrio/server/manager/views/parcels.py +25 -2
  35. karrio/server/manager/views/products.py +239 -0
  36. karrio/server/manager/views/trackers.py +69 -1
  37. {karrio_server_manager-2026.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/METADATA +1 -1
  38. {karrio_server_manager-2026.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/RECORD +40 -28
  39. {karrio_server_manager-2026.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/WHEEL +1 -1
  40. karrio/server/manager/serializers/customs.py +0 -84
  41. karrio/server/manager/tests/test_custom_infos.py +0 -101
  42. karrio/server/manager/views/customs.py +0 -159
  43. {karrio_server_manager-2026.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,6 @@
1
1
  import uuid
2
2
  import typing
3
+ import datetime
3
4
  import rest_framework.status as status
4
5
  import django.db.transaction as transaction
5
6
  from rest_framework.reverse import reverse
@@ -9,7 +10,7 @@ import karrio.core.units as units
9
10
  import karrio.server.conf as conf
10
11
  import karrio.server.core.utils as utils
11
12
  import karrio.server.core.gateway as gateway
12
-
13
+ from karrio.server.core.utils import create_carrier_snapshot, resolve_carrier
13
14
  import karrio.server.core.dataunits as dataunits
14
15
  import karrio.server.core.datatypes as datatypes
15
16
  import karrio.server.core.exceptions as exceptions
@@ -22,12 +23,13 @@ from karrio.server.serializers import (
22
23
  ChoiceField,
23
24
  BooleanField,
24
25
  owned_model_serializer,
25
- save_one_to_one_data,
26
- save_many_to_many_data,
27
26
  link_org,
28
27
  Context,
29
28
  PlainDictField,
30
29
  StringListField,
30
+ process_json_object_mutation,
31
+ process_json_array_mutation,
32
+ process_customs_mutation,
31
33
  )
32
34
  from karrio.server.core.serializers import (
33
35
  SHIPMENT_STATUS,
@@ -43,11 +45,9 @@ from karrio.server.core.serializers import (
43
45
  Payment,
44
46
  Message,
45
47
  Rate,
48
+ Parcel,
46
49
  )
47
50
  from karrio.server.manager.serializers.document import DocumentUploadSerializer
48
- from karrio.server.manager.serializers.address import AddressSerializer
49
- from karrio.server.manager.serializers.customs import CustomsSerializer
50
- from karrio.server.manager.serializers.parcel import ParcelSerializer
51
51
  from karrio.server.manager.serializers.rate import RateSerializer
52
52
  import karrio.server.manager.models as models
53
53
 
@@ -68,64 +68,8 @@ class ShipmentSerializer(ShipmentData):
68
68
  docs = Documents(required=False)
69
69
  meta = PlainDictField(required=False, allow_null=True)
70
70
  messages = Message(many=True, required=False, default=[])
71
-
72
- def __init__(self, instance: models.Shipment = None, **kwargs):
73
- data = kwargs.get("data") or {}
74
- context = getattr(self, "__context", None) or kwargs.get("context")
75
- is_update = instance is not None
76
-
77
- if is_update and ("parcels" in data):
78
- save_many_to_many_data(
79
- "parcels",
80
- ParcelSerializer,
81
- instance,
82
- payload=data,
83
- context=context,
84
- partial=True,
85
- )
86
- if is_update and ("customs" in data):
87
- instance.customs = save_one_to_one_data(
88
- "customs",
89
- CustomsSerializer,
90
- instance,
91
- payload=data,
92
- context=context,
93
- partial=instance.customs is not None,
94
- )
95
- if is_update and ("recipient" in data):
96
- instance.recipient = save_one_to_one_data(
97
- "recipient",
98
- AddressSerializer,
99
- instance,
100
- payload=data,
101
- context=context,
102
- )
103
- if is_update and ("return_address" in data):
104
- instance.return_address = save_one_to_one_data(
105
- "return_address",
106
- AddressSerializer,
107
- instance,
108
- payload=data,
109
- context=context,
110
- )
111
- if is_update and ("billing_address" in data):
112
- instance.billing_address = save_one_to_one_data(
113
- "billing_address",
114
- AddressSerializer,
115
- instance,
116
- payload=data,
117
- context=context,
118
- )
119
- if is_update and ("shipper" in data):
120
- instance.shipper = save_one_to_one_data(
121
- "shipper",
122
- AddressSerializer,
123
- instance,
124
- payload=data,
125
- context=context,
126
- )
127
-
128
- super().__init__(instance, **kwargs)
71
+ # Override parcels to use Parcel (with id) instead of ParcelData (without id)
72
+ parcels = Parcel(many=True, allow_empty=False, help_text="The shipment's parcels")
129
73
 
130
74
  @transaction.atomic
131
75
  def create(
@@ -154,7 +98,7 @@ class ShipmentSerializer(ShipmentData):
154
98
  )
155
99
  )
156
100
 
157
- carriers = gateway.Carriers.list(
101
+ carriers = gateway.Connections.list(
158
102
  context=context,
159
103
  carrier_ids=carrier_ids,
160
104
  **({"carrier_name": resolved_carrier_name} if resolved_carrier_name else {}),
@@ -195,6 +139,46 @@ class ShipmentSerializer(ShipmentData):
195
139
  context=context,
196
140
  )
197
141
 
142
+ # Process JSON fields for addresses, parcels, and customs
143
+ json_fields = {}
144
+
145
+ if "shipper" in validated_data:
146
+ json_fields.update(shipper=process_json_object_mutation(
147
+ "shipper", validated_data, None,
148
+ model_class=models.Address, object_type="address", id_prefix="adr",
149
+ ))
150
+
151
+ if "recipient" in validated_data:
152
+ json_fields.update(recipient=process_json_object_mutation(
153
+ "recipient", validated_data, None,
154
+ model_class=models.Address, object_type="address", id_prefix="adr",
155
+ ))
156
+
157
+ if "return_address" in validated_data:
158
+ json_fields.update(return_address=process_json_object_mutation(
159
+ "return_address", validated_data, None,
160
+ model_class=models.Address, object_type="address", id_prefix="adr",
161
+ ))
162
+
163
+ if "billing_address" in validated_data:
164
+ json_fields.update(billing_address=process_json_object_mutation(
165
+ "billing_address", validated_data, None,
166
+ model_class=models.Address, object_type="address", id_prefix="adr",
167
+ ))
168
+
169
+ json_fields.update(parcels=process_json_array_mutation(
170
+ "parcels", validated_data, None,
171
+ id_prefix="pcl", model_class=models.Parcel,
172
+ nested_arrays={"items": ("itm", models.Commodity)},
173
+ object_type="parcel", data_field_name="parcels",
174
+ ))
175
+
176
+ if "customs" in validated_data:
177
+ json_fields.update(customs=process_customs_mutation(
178
+ validated_data, None,
179
+ address_model=models.Address, product_model=models.Commodity,
180
+ ))
181
+
198
182
  shipment = models.Shipment.objects.create(
199
183
  **{
200
184
  **{
@@ -202,36 +186,7 @@ class ShipmentSerializer(ShipmentData):
202
186
  for key, value in validated_data.items()
203
187
  if key in models.Shipment.DIRECT_PROPS and value is not None
204
188
  },
205
- "customs": save_one_to_one_data(
206
- "customs",
207
- CustomsSerializer,
208
- payload=validated_data,
209
- context=context,
210
- ),
211
- "shipper": save_one_to_one_data(
212
- "shipper",
213
- AddressSerializer,
214
- payload=validated_data,
215
- context=context,
216
- ),
217
- "recipient": save_one_to_one_data(
218
- "recipient",
219
- AddressSerializer,
220
- payload=validated_data,
221
- context=context,
222
- ),
223
- "return_address": save_one_to_one_data(
224
- "return_address",
225
- AddressSerializer,
226
- payload=validated_data,
227
- context=context,
228
- ),
229
- "billing_address": save_one_to_one_data(
230
- "billing_address",
231
- AddressSerializer,
232
- payload=validated_data,
233
- context=context,
234
- ),
189
+ **json_fields,
235
190
  "rates": rates,
236
191
  "payment": payment,
237
192
  "services": services,
@@ -240,18 +195,12 @@ class ShipmentSerializer(ShipmentData):
240
195
  }
241
196
  )
242
197
 
243
- shipment.carriers.set(carriers if any(carrier_ids) else [])
244
-
245
- save_many_to_many_data(
246
- "parcels",
247
- ParcelSerializer,
248
- shipment,
249
- payload=validated_data,
250
- context=context,
251
- )
198
+ # carriers M2M removed - carrier info now in selected_rate JSON
252
199
 
253
200
  # Buy label if preferred service is selected, shipping method applied, shipping rules applied, or skip rate fetching
254
201
  if (service and fetch_rates) or apply_shipping_method_flag or apply_shipping_rules or skip_rate_fetching:
202
+ from karrio.server.tracing.utils import set_tracing_context
203
+ set_tracing_context(object_id=shipment.id)
255
204
  return buy_shipment_label(
256
205
  shipment,
257
206
  context=context,
@@ -274,13 +223,73 @@ class ShipmentSerializer(ShipmentData):
274
223
  changes.append(key)
275
224
  validated_data.pop(key)
276
225
 
277
- if key in models.Shipment.RELATIONAL_PROPS and val is None:
278
- prop = getattr(instance, key)
279
- # Delete related data from database if payload set to null
280
- if hasattr(prop, "delete"):
281
- prop.delete(keep_parents=True)
282
- setattr(instance, key, None)
283
- validated_data.pop(key)
226
+ # Note: RELATIONAL_PROPS handling removed - FK relationships converted to JSONFields
227
+
228
+ if "shipper" in data:
229
+ instance.shipper = process_json_object_mutation(
230
+ "shipper",
231
+ data,
232
+ instance,
233
+ model_class=models.Address,
234
+ object_type="address",
235
+ id_prefix="adr",
236
+ )
237
+ changes.append("shipper")
238
+
239
+ if "recipient" in data:
240
+ instance.recipient = process_json_object_mutation(
241
+ "recipient",
242
+ data,
243
+ instance,
244
+ model_class=models.Address,
245
+ object_type="address",
246
+ id_prefix="adr",
247
+ )
248
+ changes.append("recipient")
249
+
250
+ if "return_address" in data:
251
+ instance.return_address = process_json_object_mutation(
252
+ "return_address",
253
+ data,
254
+ instance,
255
+ model_class=models.Address,
256
+ object_type="address",
257
+ id_prefix="adr",
258
+ )
259
+ changes.append("return_address")
260
+
261
+ if "billing_address" in data:
262
+ instance.billing_address = process_json_object_mutation(
263
+ "billing_address",
264
+ data,
265
+ instance,
266
+ model_class=models.Address,
267
+ object_type="address",
268
+ id_prefix="adr",
269
+ )
270
+ changes.append("billing_address")
271
+
272
+ if "parcels" in data:
273
+ instance.parcels = process_json_array_mutation(
274
+ "parcels",
275
+ data,
276
+ instance,
277
+ id_prefix="pcl",
278
+ model_class=models.Parcel,
279
+ nested_arrays={"items": ("itm", models.Commodity)},
280
+ object_type="parcel",
281
+ data_field_name="parcels",
282
+ )
283
+ changes.append("parcels")
284
+
285
+ if "customs" in data:
286
+ instance.customs = process_customs_mutation(
287
+ data,
288
+ instance,
289
+ address_model=models.Address,
290
+ product_model=models.Commodity,
291
+ )
292
+ changes.append("customs")
284
293
 
285
294
  if "docs" in validated_data:
286
295
  changes.append("label")
@@ -296,30 +305,24 @@ class ShipmentSerializer(ShipmentData):
296
305
 
297
306
  if "selected_rate" in validated_data:
298
307
  selected_rate = validated_data.get("selected_rate", {})
299
- carrier = providers.Carrier.objects.filter(
308
+ # Try to find carrier for connection metadata
309
+ carrier = providers.CarrierConnection.objects.filter(
300
310
  carrier_id=selected_rate.get("carrier_id")
301
311
  ).first()
302
312
  instance.test_mode = selected_rate.get("test_mode", instance.test_mode)
303
313
 
304
- instance.selected_rate = {
305
- **selected_rate,
306
- "meta": {
307
- **selected_rate.get("meta", {}),
308
- **(
309
- {"carrier_connection_id": carrier.id}
310
- if carrier is not None
311
- else {}
312
- ),
313
- },
314
- }
315
- instance.selected_rate_carrier = carrier
316
- changes += ["selected_rate", "selected_rate_carrier"]
314
+ # Store carrier snapshot in dedicated field (consistent with Tracking, Pickup, etc.)
315
+ if carrier:
316
+ instance.carrier = create_carrier_snapshot(carrier)
317
+ changes += ["carrier"]
318
+
319
+ instance.selected_rate = selected_rate
320
+ changes += ["selected_rate"]
317
321
 
318
322
  if any(changes):
319
323
  instance.save(update_fields=changes)
320
324
 
321
- if "carrier_ids" in validated_data:
322
- instance.carriers.set(carriers)
325
+ # carriers M2M removed - carrier info now in selected_rate JSON
323
326
 
324
327
  return instance
325
328
 
@@ -567,9 +570,11 @@ class ShipmentPurchaseSerializer(Shipment):
567
570
 
568
571
  class ShipmentCancelSerializer(Shipment):
569
572
  def update(
570
- self, instance: models.Shipment, validated_data: dict, **kwargs
573
+ self, instance: models.Shipment, validated_data: dict, context=None, **kwargs
571
574
  ) -> datatypes.ConfirmationResponse:
572
575
  if instance.status == ShipmentStatus.purchased.value:
576
+ # Resolve carrier from carrier snapshot
577
+ carrier = resolve_carrier(instance.carrier or {}, context)
573
578
  gateway.Shipments.cancel(
574
579
  payload={
575
580
  **ShipmentCancelRequest(instance).data,
@@ -579,7 +584,7 @@ class ShipmentCancelSerializer(Shipment):
579
584
  **(validated_data.get("options") or {}),
580
585
  },
581
586
  },
582
- carrier=instance.selected_rate_carrier,
587
+ carrier=carrier,
583
588
  )
584
589
 
585
590
  instance.status = ShipmentStatus.cancelled.value
@@ -604,9 +609,10 @@ def fetch_shipment_rates(
604
609
  context: typing.Any,
605
610
  data: dict = dict(),
606
611
  ) -> models.Shipment:
607
- carrier_ids = data["carrier_ids"] if "carrier_ids" in data else shipment.carrier_ids
612
+ # carrier_ids can be passed in data, or default to empty list (query all carriers)
613
+ carrier_ids = data.get("carrier_ids", [])
608
614
 
609
- carriers = gateway.Carriers.list(
615
+ carriers = gateway.Connections.list(
610
616
  active=True,
611
617
  capability="shipping",
612
618
  context=context,
@@ -652,7 +658,7 @@ def buy_shipment_label(
652
658
  invoice_template = shipment.options.get("invoice_template")
653
659
 
654
660
  payload = {**data, "selected_rate_id": selected_rate.get("id")}
655
- carrier = gateway.Carriers.first(
661
+ carrier = gateway.Connections.first(
656
662
  carrier_id=selected_rate.get("carrier_id"),
657
663
  test_mode=selected_rate.get("test_mode"),
658
664
  context=context,
@@ -666,8 +672,9 @@ def buy_shipment_label(
666
672
 
667
673
  # Generate invoice in advance if is_paperless_trade
668
674
  if pre_purchase_generation:
675
+ # Set carrier snapshot on shipment (consistent with other models)
676
+ shipment.carrier = create_carrier_snapshot(carrier)
669
677
  shipment.selected_rate = selected_rate
670
- shipment.selected_rate_carrier = carrier
671
678
  document = generate_custom_invoice(invoice_template, shipment)
672
679
  invoice = dict(invoice=document["doc_file"])
673
680
 
@@ -694,11 +701,22 @@ def buy_shipment_label(
694
701
  .instance
695
702
  )
696
703
 
704
+ # Merge response parcel data with existing parcel data to preserve all fields (weight, etc.)
705
+ existing_parcels = shipment.parcels or []
706
+ merged_parcels = []
707
+ for idx, response_parcel in enumerate(response.parcels):
708
+ existing_parcel = existing_parcels[idx] if idx < len(existing_parcels) else {}
709
+ merged_parcels.append(
710
+ {
711
+ **existing_parcel, # Keep existing data (weight, weight_unit, etc.)
712
+ "id": response_parcel.id or existing_parcel.get("id"),
713
+ "reference_number": response_parcel.reference_number
714
+ or existing_parcel.get("reference_number"),
715
+ }
716
+ )
717
+
697
718
  extra.update(
698
- parcels=[
699
- dict(id=parcel.id, reference_number=parcel.reference_number)
700
- for parcel in response.parcels
701
- ],
719
+ parcels=merged_parcels,
702
720
  docs={**lib.to_dict(response.docs), **invoice},
703
721
  )
704
722
 
@@ -714,6 +732,13 @@ def buy_shipment_label(
714
732
  ),
715
733
  }
716
734
 
735
+ # Set selected_rate with carrier snapshot directly on shipment before update
736
+ # (This is more reliable than depending on serializer validation)
737
+ # Set carrier snapshot on shipment (consistent with other models)
738
+ shipment.carrier = create_carrier_snapshot(carrier)
739
+ shipment.selected_rate = selected_rate
740
+ shipment.save(update_fields=["carrier", "selected_rate"])
741
+
717
742
  purchased_shipment = lib.identity(
718
743
  ShipmentSerializer.map(
719
744
  shipment,
@@ -808,6 +833,44 @@ def can_mutate_shipment(
808
833
  )
809
834
 
810
835
 
836
+ def compute_estimated_delivery(
837
+ selected_rate: typing.Optional[dict],
838
+ options: typing.Optional[dict],
839
+ ) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]:
840
+ """Compute estimated delivery date from rate and shipment options.
841
+
842
+ This function extracts the estimated delivery date from the selected rate,
843
+ or computes it from transit days and shipping date if not directly available.
844
+
845
+ Args:
846
+ selected_rate: The selected shipping rate dictionary
847
+ options: The shipment options dictionary
848
+
849
+ Returns:
850
+ A tuple of (estimated_delivery, shipping_date_str) where:
851
+ - estimated_delivery: The estimated delivery date string (YYYY-MM-DD format) or None
852
+ - shipping_date_str: The shipping date string from options or None
853
+ """
854
+ _rate = selected_rate or {}
855
+ _options = options or {}
856
+
857
+ shipping_date_str = _options.get("shipping_date") or _options.get("shipment_date")
858
+ estimated_delivery = _rate.get("estimated_delivery")
859
+ transit_days = _rate.get("transit_days")
860
+
861
+ if not estimated_delivery and transit_days and shipping_date_str:
862
+ shipping_date = lib.to_date(
863
+ shipping_date_str,
864
+ current_format="%Y-%m-%dT%H:%M",
865
+ try_formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"],
866
+ )
867
+ if shipping_date:
868
+ estimated_date = shipping_date + datetime.timedelta(days=int(transit_days))
869
+ estimated_delivery = lib.fdate(estimated_date)
870
+
871
+ return estimated_delivery, shipping_date_str
872
+
873
+
811
874
  def remove_shipment_tracker(shipment: models.Shipment):
812
875
  if hasattr(shipment, "shipment_tracker"):
813
876
  shipment.shipment_tracker.delete()
@@ -815,21 +878,27 @@ def remove_shipment_tracker(shipment: models.Shipment):
815
878
 
816
879
  def create_shipment_tracker(shipment: typing.Optional[models.Shipment], context):
817
880
  rate_provider = (shipment.meta or {}).get("rate_provider") or shipment.carrier_name
818
- carrier = shipment.selected_rate_carrier
881
+ # Resolve carrier from carrier snapshot
882
+ carrier_snapshot = shipment.carrier or {}
883
+ carrier = resolve_carrier(carrier_snapshot, context)
819
884
 
820
885
  # Get rate provider carrier if supported instead of carrier account
821
886
  if (
822
887
  rate_provider != shipment.carrier_name
823
888
  ) and rate_provider in dataunits.CARRIER_NAMES:
824
889
  carrier = (
825
- providers.Carrier.access_by(context)
890
+ providers.CarrierConnection.access_by(context)
826
891
  .filter(carrier_code=rate_provider)
827
892
  .first()
828
893
  )
829
894
 
830
- # Handle hub extension tracking
831
- if shipment.selected_rate_carrier.gateway.is_hub and carrier is None:
832
- carrier = shipment.selected_rate_carrier
895
+ # Handle hub extension tracking - resolve from snapshot if carrier is None
896
+ if carrier and carrier.gateway.is_hub:
897
+ # Keep the hub carrier
898
+ pass
899
+ elif carrier is None and carrier_snapshot:
900
+ # Try to resolve again if carrier is None
901
+ carrier = resolve_carrier(carrier_snapshot, context)
833
902
 
834
903
  # Get dhl universal account if a dhl integration doesn't support tracking API
835
904
  if (
@@ -837,7 +906,7 @@ def create_shipment_tracker(shipment: typing.Optional[models.Shipment], context)
837
906
  and "dhl" in carrier.carrier_name
838
907
  and "get_tracking" not in carrier.gateway.proxy_methods
839
908
  ):
840
- carrier = gateway.Carriers.first(
909
+ carrier = gateway.Connections.first(
841
910
  carrier_name="dhl_universal",
842
911
  context=context,
843
912
  )
@@ -845,29 +914,40 @@ def create_shipment_tracker(shipment: typing.Optional[models.Shipment], context)
845
914
  if carrier is not None and "get_tracking" in carrier.gateway.proxy_methods:
846
915
  # Create shipment tracker
847
916
  try:
848
- pkg_weight = sum([p.weight or 0.0 for p in shipment.parcels.all()], 0.0)
917
+ # Use JSON fields for data access
918
+ parcels = shipment.parcels or []
919
+ shipper = shipment.shipper or {}
920
+ recipient = shipment.recipient or {}
921
+
922
+ pkg_weight = sum([p.get("weight") or 0.0 for p in parcels], 0.0)
923
+ estimated_delivery, shipping_date_str = compute_estimated_delivery(
924
+ shipment.selected_rate, shipment.options
925
+ )
926
+
849
927
  tracker = models.Tracking.objects.create(
850
928
  tracking_number=shipment.tracking_number,
851
929
  delivered=False,
852
930
  shipment=shipment,
853
- tracking_carrier=carrier,
931
+ carrier=create_carrier_snapshot(carrier),
854
932
  test_mode=carrier.test_mode,
855
933
  created_by=shipment.created_by,
856
934
  status=TrackerStatus.pending.value,
935
+ estimated_delivery=estimated_delivery,
857
936
  events=utils.default_tracking_event(event_at=shipment.updated_at),
858
937
  options={shipment.tracking_number: dict(carrier=rate_provider)},
859
938
  meta=dict(carrier=rate_provider),
860
939
  info=dict(
861
940
  source="api",
862
941
  shipment_weight=str(pkg_weight),
863
- shipment_package_count=str(shipment.parcels.count()),
864
- customer_name=shipment.recipient.person_name,
865
- shipment_origin_country=shipment.shipper.country_code,
866
- shipment_origin_postal_code=shipment.shipper.postal_code,
867
- shipment_destination_country=shipment.recipient.country_code,
868
- shipment_destination_postal_code=shipment.recipient.postal_code,
942
+ shipment_package_count=str(len(parcels)),
943
+ customer_name=recipient.get("person_name"),
944
+ shipment_origin_country=shipper.get("country_code"),
945
+ shipment_origin_postal_code=shipper.get("postal_code"),
946
+ shipment_destination_country=recipient.get("country_code"),
947
+ shipment_destination_postal_code=recipient.get("postal_code"),
869
948
  shipment_service=shipment.meta.get("service_name"),
870
- shipping_date=shipment.options.get("shipment_date"),
949
+ shipping_date=shipping_date_str,
950
+ expected_delivery=estimated_delivery,
871
951
  carrier_tracking_link=utils.get_carrier_tracking_link(
872
952
  carrier, shipment.tracking_number
873
953
  ),