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.
- karrio/server/manager/migrations/0070_add_meta_and_product_fields.py +98 -0
- karrio/server/manager/migrations/0071_product_proxy.py +25 -0
- karrio/server/manager/migrations/0072_populate_json_fields.py +267 -0
- karrio/server/manager/migrations/0073_make_shipment_fk_nullable.py +36 -0
- karrio/server/manager/migrations/0074_clean_model_refactoring.py +207 -0
- karrio/server/manager/migrations/0075_populate_template_meta.py +69 -0
- karrio/server/manager/migrations/0076_remove_customs_model.py +66 -0
- karrio/server/manager/migrations/0077_add_carrier_snapshot_fields.py +83 -0
- karrio/server/manager/migrations/0078_populate_carrier_snapshots.py +112 -0
- karrio/server/manager/migrations/0079_remove_carrier_fk_fields.py +56 -0
- karrio/server/manager/migrations/0080_add_carrier_json_indexes.py +137 -0
- karrio/server/manager/migrations/0081_cleanup.py +62 -0
- karrio/server/manager/migrations/0082_shipment_fees.py +26 -0
- karrio/server/manager/models.py +421 -321
- karrio/server/manager/serializers/__init__.py +5 -4
- karrio/server/manager/serializers/address.py +8 -2
- karrio/server/manager/serializers/commodity.py +11 -4
- karrio/server/manager/serializers/document.py +29 -15
- karrio/server/manager/serializers/manifest.py +6 -3
- karrio/server/manager/serializers/parcel.py +5 -2
- karrio/server/manager/serializers/pickup.py +194 -67
- karrio/server/manager/serializers/shipment.py +232 -152
- karrio/server/manager/serializers/tracking.py +53 -12
- karrio/server/manager/tests/__init__.py +0 -1
- karrio/server/manager/tests/test_addresses.py +53 -0
- karrio/server/manager/tests/test_parcels.py +50 -0
- karrio/server/manager/tests/test_pickups.py +286 -50
- karrio/server/manager/tests/test_products.py +597 -0
- karrio/server/manager/tests/test_shipments.py +237 -92
- karrio/server/manager/tests/test_trackers.py +65 -1
- karrio/server/manager/views/__init__.py +1 -1
- karrio/server/manager/views/addresses.py +38 -2
- karrio/server/manager/views/documents.py +1 -1
- karrio/server/manager/views/parcels.py +25 -2
- karrio/server/manager/views/products.py +239 -0
- karrio/server/manager/views/trackers.py +69 -1
- {karrio_server_manager-2026.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/METADATA +1 -1
- {karrio_server_manager-2026.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/RECORD +40 -28
- {karrio_server_manager-2026.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/WHEEL +1 -1
- karrio/server/manager/serializers/customs.py +0 -84
- karrio/server/manager/tests/test_custom_infos.py +0 -101
- karrio/server/manager/views/customs.py +0 -159
- {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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
832
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
864
|
-
customer_name=
|
|
865
|
-
shipment_origin_country=
|
|
866
|
-
shipment_origin_postal_code=
|
|
867
|
-
shipment_destination_country=
|
|
868
|
-
shipment_destination_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=
|
|
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
|
),
|