karrio-server-manager 2026.1.1__py3-none-any.whl → 2026.1.4__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 (44) 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 +226 -171
  23. karrio/server/manager/serializers/tracking.py +45 -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 +4 -3
  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/pickups.py +6 -6
  36. karrio/server/manager/views/products.py +239 -0
  37. karrio/server/manager/views/trackers.py +69 -1
  38. {karrio_server_manager-2026.1.1.dist-info → karrio_server_manager-2026.1.4.dist-info}/METADATA +1 -1
  39. {karrio_server_manager-2026.1.1.dist-info → karrio_server_manager-2026.1.4.dist-info}/RECORD +41 -29
  40. {karrio_server_manager-2026.1.1.dist-info → karrio_server_manager-2026.1.4.dist-info}/WHEEL +1 -1
  41. karrio/server/manager/serializers/customs.py +0 -84
  42. karrio/server/manager/tests/test_custom_infos.py +0 -101
  43. karrio/server/manager/views/customs.py +0 -159
  44. {karrio_server_manager-2026.1.1.dist-info → karrio_server_manager-2026.1.4.dist-info}/top_level.txt +0 -0
@@ -10,7 +10,7 @@ import karrio.core.units as units
10
10
  import karrio.server.conf as conf
11
11
  import karrio.server.core.utils as utils
12
12
  import karrio.server.core.gateway as gateway
13
-
13
+ from karrio.server.core.utils import create_carrier_snapshot, resolve_carrier
14
14
  import karrio.server.core.dataunits as dataunits
15
15
  import karrio.server.core.datatypes as datatypes
16
16
  import karrio.server.core.exceptions as exceptions
@@ -23,12 +23,13 @@ from karrio.server.serializers import (
23
23
  ChoiceField,
24
24
  BooleanField,
25
25
  owned_model_serializer,
26
- save_one_to_one_data,
27
- save_many_to_many_data,
28
26
  link_org,
29
27
  Context,
30
28
  PlainDictField,
31
29
  StringListField,
30
+ process_json_object_mutation,
31
+ process_json_array_mutation,
32
+ process_customs_mutation,
32
33
  )
33
34
  from karrio.server.core.serializers import (
34
35
  SHIPMENT_STATUS,
@@ -44,11 +45,9 @@ from karrio.server.core.serializers import (
44
45
  Payment,
45
46
  Message,
46
47
  Rate,
48
+ Parcel,
47
49
  )
48
50
  from karrio.server.manager.serializers.document import DocumentUploadSerializer
49
- from karrio.server.manager.serializers.address import AddressSerializer
50
- from karrio.server.manager.serializers.customs import CustomsSerializer
51
- from karrio.server.manager.serializers.parcel import ParcelSerializer
52
51
  from karrio.server.manager.serializers.rate import RateSerializer
53
52
  import karrio.server.manager.models as models
54
53
 
@@ -69,64 +68,8 @@ class ShipmentSerializer(ShipmentData):
69
68
  docs = Documents(required=False)
70
69
  meta = PlainDictField(required=False, allow_null=True)
71
70
  messages = Message(many=True, required=False, default=[])
72
-
73
- def __init__(self, instance: models.Shipment = None, **kwargs):
74
- data = kwargs.get("data") or {}
75
- context = getattr(self, "__context", None) or kwargs.get("context")
76
- is_update = instance is not None
77
-
78
- if is_update and ("parcels" in data):
79
- save_many_to_many_data(
80
- "parcels",
81
- ParcelSerializer,
82
- instance,
83
- payload=data,
84
- context=context,
85
- partial=True,
86
- )
87
- if is_update and ("customs" in data):
88
- instance.customs = save_one_to_one_data(
89
- "customs",
90
- CustomsSerializer,
91
- instance,
92
- payload=data,
93
- context=context,
94
- partial=instance.customs is not None,
95
- )
96
- if is_update and ("recipient" in data):
97
- instance.recipient = save_one_to_one_data(
98
- "recipient",
99
- AddressSerializer,
100
- instance,
101
- payload=data,
102
- context=context,
103
- )
104
- if is_update and ("return_address" in data):
105
- instance.return_address = save_one_to_one_data(
106
- "return_address",
107
- AddressSerializer,
108
- instance,
109
- payload=data,
110
- context=context,
111
- )
112
- if is_update and ("billing_address" in data):
113
- instance.billing_address = save_one_to_one_data(
114
- "billing_address",
115
- AddressSerializer,
116
- instance,
117
- payload=data,
118
- context=context,
119
- )
120
- if is_update and ("shipper" in data):
121
- instance.shipper = save_one_to_one_data(
122
- "shipper",
123
- AddressSerializer,
124
- instance,
125
- payload=data,
126
- context=context,
127
- )
128
-
129
- 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")
130
73
 
131
74
  @transaction.atomic
132
75
  def create(
@@ -155,7 +98,7 @@ class ShipmentSerializer(ShipmentData):
155
98
  )
156
99
  )
157
100
 
158
- carriers = gateway.Carriers.list(
101
+ carriers = gateway.Connections.list(
159
102
  context=context,
160
103
  carrier_ids=carrier_ids,
161
104
  **({"carrier_name": resolved_carrier_name} if resolved_carrier_name else {}),
@@ -196,6 +139,46 @@ class ShipmentSerializer(ShipmentData):
196
139
  context=context,
197
140
  )
198
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
+
199
182
  shipment = models.Shipment.objects.create(
200
183
  **{
201
184
  **{
@@ -203,36 +186,7 @@ class ShipmentSerializer(ShipmentData):
203
186
  for key, value in validated_data.items()
204
187
  if key in models.Shipment.DIRECT_PROPS and value is not None
205
188
  },
206
- "customs": save_one_to_one_data(
207
- "customs",
208
- CustomsSerializer,
209
- payload=validated_data,
210
- context=context,
211
- ),
212
- "shipper": save_one_to_one_data(
213
- "shipper",
214
- AddressSerializer,
215
- payload=validated_data,
216
- context=context,
217
- ),
218
- "recipient": save_one_to_one_data(
219
- "recipient",
220
- AddressSerializer,
221
- payload=validated_data,
222
- context=context,
223
- ),
224
- "return_address": save_one_to_one_data(
225
- "return_address",
226
- AddressSerializer,
227
- payload=validated_data,
228
- context=context,
229
- ),
230
- "billing_address": save_one_to_one_data(
231
- "billing_address",
232
- AddressSerializer,
233
- payload=validated_data,
234
- context=context,
235
- ),
189
+ **json_fields,
236
190
  "rates": rates,
237
191
  "payment": payment,
238
192
  "services": services,
@@ -241,18 +195,12 @@ class ShipmentSerializer(ShipmentData):
241
195
  }
242
196
  )
243
197
 
244
- shipment.carriers.set(carriers if any(carrier_ids) else [])
245
-
246
- save_many_to_many_data(
247
- "parcels",
248
- ParcelSerializer,
249
- shipment,
250
- payload=validated_data,
251
- context=context,
252
- )
198
+ # carriers M2M removed - carrier info now in selected_rate JSON
253
199
 
254
200
  # Buy label if preferred service is selected, shipping method applied, shipping rules applied, or skip rate fetching
255
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)
256
204
  return buy_shipment_label(
257
205
  shipment,
258
206
  context=context,
@@ -275,13 +223,73 @@ class ShipmentSerializer(ShipmentData):
275
223
  changes.append(key)
276
224
  validated_data.pop(key)
277
225
 
278
- if key in models.Shipment.RELATIONAL_PROPS and val is None:
279
- prop = getattr(instance, key)
280
- # Delete related data from database if payload set to null
281
- if hasattr(prop, "delete"):
282
- prop.delete(keep_parents=True)
283
- setattr(instance, key, None)
284
- 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")
285
293
 
286
294
  if "docs" in validated_data:
287
295
  changes.append("label")
@@ -297,30 +305,24 @@ class ShipmentSerializer(ShipmentData):
297
305
 
298
306
  if "selected_rate" in validated_data:
299
307
  selected_rate = validated_data.get("selected_rate", {})
300
- carrier = providers.Carrier.objects.filter(
308
+ # Try to find carrier for connection metadata
309
+ carrier = providers.CarrierConnection.objects.filter(
301
310
  carrier_id=selected_rate.get("carrier_id")
302
311
  ).first()
303
312
  instance.test_mode = selected_rate.get("test_mode", instance.test_mode)
304
313
 
305
- instance.selected_rate = {
306
- **selected_rate,
307
- "meta": {
308
- **selected_rate.get("meta", {}),
309
- **(
310
- {"carrier_connection_id": carrier.id}
311
- if carrier is not None
312
- else {}
313
- ),
314
- },
315
- }
316
- instance.selected_rate_carrier = carrier
317
- 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"]
318
321
 
319
322
  if any(changes):
320
323
  instance.save(update_fields=changes)
321
324
 
322
- if "carrier_ids" in validated_data:
323
- instance.carriers.set(carriers)
325
+ # carriers M2M removed - carrier info now in selected_rate JSON
324
326
 
325
327
  return instance
326
328
 
@@ -568,9 +570,11 @@ class ShipmentPurchaseSerializer(Shipment):
568
570
 
569
571
  class ShipmentCancelSerializer(Shipment):
570
572
  def update(
571
- self, instance: models.Shipment, validated_data: dict, **kwargs
573
+ self, instance: models.Shipment, validated_data: dict, context=None, **kwargs
572
574
  ) -> datatypes.ConfirmationResponse:
573
575
  if instance.status == ShipmentStatus.purchased.value:
576
+ # Resolve carrier from carrier snapshot
577
+ carrier = resolve_carrier(instance.carrier or {}, context)
574
578
  gateway.Shipments.cancel(
575
579
  payload={
576
580
  **ShipmentCancelRequest(instance).data,
@@ -580,7 +584,7 @@ class ShipmentCancelSerializer(Shipment):
580
584
  **(validated_data.get("options") or {}),
581
585
  },
582
586
  },
583
- carrier=instance.selected_rate_carrier,
587
+ carrier=carrier,
584
588
  )
585
589
 
586
590
  instance.status = ShipmentStatus.cancelled.value
@@ -605,9 +609,10 @@ def fetch_shipment_rates(
605
609
  context: typing.Any,
606
610
  data: dict = dict(),
607
611
  ) -> models.Shipment:
608
- 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", [])
609
614
 
610
- carriers = gateway.Carriers.list(
615
+ carriers = gateway.Connections.list(
611
616
  active=True,
612
617
  capability="shipping",
613
618
  context=context,
@@ -653,7 +658,7 @@ def buy_shipment_label(
653
658
  invoice_template = shipment.options.get("invoice_template")
654
659
 
655
660
  payload = {**data, "selected_rate_id": selected_rate.get("id")}
656
- carrier = gateway.Carriers.first(
661
+ carrier = gateway.Connections.first(
657
662
  carrier_id=selected_rate.get("carrier_id"),
658
663
  test_mode=selected_rate.get("test_mode"),
659
664
  context=context,
@@ -667,8 +672,9 @@ def buy_shipment_label(
667
672
 
668
673
  # Generate invoice in advance if is_paperless_trade
669
674
  if pre_purchase_generation:
675
+ # Set carrier snapshot on shipment (consistent with other models)
676
+ shipment.carrier = create_carrier_snapshot(carrier)
670
677
  shipment.selected_rate = selected_rate
671
- shipment.selected_rate_carrier = carrier
672
678
  document = generate_custom_invoice(invoice_template, shipment)
673
679
  invoice = dict(invoice=document["doc_file"])
674
680
 
@@ -695,11 +701,22 @@ def buy_shipment_label(
695
701
  .instance
696
702
  )
697
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
+
698
718
  extra.update(
699
- parcels=[
700
- dict(id=parcel.id, reference_number=parcel.reference_number)
701
- for parcel in response.parcels
702
- ],
719
+ parcels=merged_parcels,
703
720
  docs={**lib.to_dict(response.docs), **invoice},
704
721
  )
705
722
 
@@ -715,6 +732,13 @@ def buy_shipment_label(
715
732
  ),
716
733
  }
717
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
+
718
742
  purchased_shipment = lib.identity(
719
743
  ShipmentSerializer.map(
720
744
  shipment,
@@ -809,6 +833,44 @@ def can_mutate_shipment(
809
833
  )
810
834
 
811
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
+
812
874
  def remove_shipment_tracker(shipment: models.Shipment):
813
875
  if hasattr(shipment, "shipment_tracker"):
814
876
  shipment.shipment_tracker.delete()
@@ -816,21 +878,27 @@ def remove_shipment_tracker(shipment: models.Shipment):
816
878
 
817
879
  def create_shipment_tracker(shipment: typing.Optional[models.Shipment], context):
818
880
  rate_provider = (shipment.meta or {}).get("rate_provider") or shipment.carrier_name
819
- carrier = shipment.selected_rate_carrier
881
+ # Resolve carrier from carrier snapshot
882
+ carrier_snapshot = shipment.carrier or {}
883
+ carrier = resolve_carrier(carrier_snapshot, context)
820
884
 
821
885
  # Get rate provider carrier if supported instead of carrier account
822
886
  if (
823
887
  rate_provider != shipment.carrier_name
824
888
  ) and rate_provider in dataunits.CARRIER_NAMES:
825
889
  carrier = (
826
- providers.Carrier.access_by(context)
890
+ providers.CarrierConnection.access_by(context)
827
891
  .filter(carrier_code=rate_provider)
828
892
  .first()
829
893
  )
830
894
 
831
- # Handle hub extension tracking
832
- if shipment.selected_rate_carrier.gateway.is_hub and carrier is None:
833
- 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)
834
902
 
835
903
  # Get dhl universal account if a dhl integration doesn't support tracking API
836
904
  if (
@@ -838,7 +906,7 @@ def create_shipment_tracker(shipment: typing.Optional[models.Shipment], context)
838
906
  and "dhl" in carrier.carrier_name
839
907
  and "get_tracking" not in carrier.gateway.proxy_methods
840
908
  ):
841
- carrier = gateway.Carriers.first(
909
+ carrier = gateway.Connections.first(
842
910
  carrier_name="dhl_universal",
843
911
  context=context,
844
912
  )
@@ -846,34 +914,21 @@ def create_shipment_tracker(shipment: typing.Optional[models.Shipment], context)
846
914
  if carrier is not None and "get_tracking" in carrier.gateway.proxy_methods:
847
915
  # Create shipment tracker
848
916
  try:
849
- pkg_weight = sum([p.weight or 0.0 for p in shipment.parcels.all()], 0.0)
850
- selected_rate = shipment.selected_rate or {}
851
- shipping_date_str = (
852
- shipment.options.get("shipping_date")
853
- or shipment.options.get("shipment_date")
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
854
925
  )
855
926
 
856
- # Get estimated_delivery from selected_rate or compute from transit_days
857
- estimated_delivery = selected_rate.get("estimated_delivery")
858
- transit_days = selected_rate.get("transit_days")
859
-
860
- if not estimated_delivery and transit_days and shipping_date_str:
861
- shipping_date = lib.to_date(
862
- shipping_date_str,
863
- current_format="%Y-%m-%dT%H:%M",
864
- try_formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"],
865
- )
866
- if shipping_date:
867
- estimated_date = shipping_date + datetime.timedelta(
868
- days=int(transit_days)
869
- )
870
- estimated_delivery = lib.fdate(estimated_date)
871
-
872
927
  tracker = models.Tracking.objects.create(
873
928
  tracking_number=shipment.tracking_number,
874
929
  delivered=False,
875
930
  shipment=shipment,
876
- tracking_carrier=carrier,
931
+ carrier=create_carrier_snapshot(carrier),
877
932
  test_mode=carrier.test_mode,
878
933
  created_by=shipment.created_by,
879
934
  status=TrackerStatus.pending.value,
@@ -884,12 +939,12 @@ def create_shipment_tracker(shipment: typing.Optional[models.Shipment], context)
884
939
  info=dict(
885
940
  source="api",
886
941
  shipment_weight=str(pkg_weight),
887
- shipment_package_count=str(shipment.parcels.count()),
888
- customer_name=shipment.recipient.person_name,
889
- shipment_origin_country=shipment.shipper.country_code,
890
- shipment_origin_postal_code=shipment.shipper.postal_code,
891
- shipment_destination_country=shipment.recipient.country_code,
892
- 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"),
893
948
  shipment_service=shipment.meta.get("service_name"),
894
949
  shipping_date=shipping_date_str,
895
950
  expected_delivery=estimated_delivery,