karrio-server-manager 2026.1.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 +226 -171
- karrio/server/manager/serializers/tracking.py +45 -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 +4 -3
- 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.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/METADATA +1 -1
- {karrio_server_manager-2026.1.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/RECORD +40 -28
- {karrio_server_manager-2026.1.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.1.dist-info → karrio_server_manager-2026.1.3.dist-info}/top_level.txt +0 -0
karrio/server/manager/models.py
CHANGED
|
@@ -4,10 +4,10 @@ import django.conf as conf
|
|
|
4
4
|
import django.urls as urls
|
|
5
5
|
import django.db.models as models
|
|
6
6
|
import django.db.models.fields as fields
|
|
7
|
+
from django.contrib.contenttypes.fields import GenericRelation
|
|
7
8
|
|
|
8
9
|
import karrio.server.core.utils as utils
|
|
9
10
|
import karrio.server.core.models as core
|
|
10
|
-
import karrio.server.providers.models as providers
|
|
11
11
|
import karrio.server.core.serializers as serializers
|
|
12
12
|
|
|
13
13
|
|
|
@@ -54,19 +54,6 @@ class CommodityManager(models.Manager):
|
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
class CustomsManager(models.Manager):
|
|
58
|
-
def get_queryset(self):
|
|
59
|
-
return (
|
|
60
|
-
super()
|
|
61
|
-
.get_queryset()
|
|
62
|
-
.select_related("duty_billing_address")
|
|
63
|
-
.prefetch_related(
|
|
64
|
-
"commodities",
|
|
65
|
-
*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
|
|
66
|
-
)
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
|
|
70
57
|
class PickupManager(models.Manager):
|
|
71
58
|
def get_queryset(self):
|
|
72
59
|
return (
|
|
@@ -86,20 +73,11 @@ class ShipmentManager(models.Manager):
|
|
|
86
73
|
.get_queryset()
|
|
87
74
|
.select_related(
|
|
88
75
|
"created_by",
|
|
89
|
-
"recipient",
|
|
90
|
-
"shipper",
|
|
91
|
-
"customs",
|
|
92
76
|
"manifest",
|
|
93
|
-
"return_address",
|
|
94
|
-
"billing_address",
|
|
95
77
|
"shipment_tracker",
|
|
96
78
|
"shipment_upload_record",
|
|
97
79
|
)
|
|
98
80
|
.prefetch_related(
|
|
99
|
-
"parcels",
|
|
100
|
-
"parcels__items",
|
|
101
|
-
"customs__commodities",
|
|
102
|
-
"customs__duty_billing_address",
|
|
103
81
|
*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
|
|
104
82
|
)
|
|
105
83
|
)
|
|
@@ -113,8 +91,6 @@ class TrackingManager(models.Manager):
|
|
|
113
91
|
.select_related(
|
|
114
92
|
"created_by",
|
|
115
93
|
"shipment",
|
|
116
|
-
"shipment__recipient",
|
|
117
|
-
"shipment__shipper",
|
|
118
94
|
)
|
|
119
95
|
.prefetch_related(
|
|
120
96
|
*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
|
|
@@ -156,16 +132,10 @@ class ManifestManager(models.Manager):
|
|
|
156
132
|
|
|
157
133
|
@core.register_model
|
|
158
134
|
class Address(core.OwnedEntity):
|
|
135
|
+
# Note: shipper_shipment, recipient_shipment, billing_address_shipment removed
|
|
136
|
+
# as shipment addresses are now stored as JSON fields
|
|
159
137
|
HIDDEN_PROPS = (
|
|
160
|
-
"shipper_shipment",
|
|
161
|
-
"recipient_shipment",
|
|
162
|
-
"billing_address_shipment",
|
|
163
138
|
*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
|
|
164
|
-
*(
|
|
165
|
-
("shipper_order", "recipient_order")
|
|
166
|
-
if conf.settings.ORDERS_MANAGEMENT
|
|
167
|
-
else tuple()
|
|
168
|
-
),
|
|
169
139
|
)
|
|
170
140
|
objects = AddressManager()
|
|
171
141
|
|
|
@@ -211,6 +181,36 @@ class Address(core.OwnedEntity):
|
|
|
211
181
|
validate_location = models.BooleanField(null=True)
|
|
212
182
|
validation = models.JSONField(blank=True, null=True)
|
|
213
183
|
|
|
184
|
+
# Template metadata: enables Address to serve as a reusable template
|
|
185
|
+
# Structure: {"label": "Warehouse A", "is_default": true, "usage": ["sender", "return"]}
|
|
186
|
+
meta = models.JSONField(
|
|
187
|
+
blank=True,
|
|
188
|
+
null=True,
|
|
189
|
+
default=functools.partial(utils.identity, value={}),
|
|
190
|
+
help_text="Template metadata: label, is_default, usage[]",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Metafields via GenericRelation
|
|
194
|
+
metafields = GenericRelation(
|
|
195
|
+
"core.Metafield",
|
|
196
|
+
related_query_name="address",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def is_template(self) -> bool:
|
|
201
|
+
"""Check if this address is a template (has meta with label)."""
|
|
202
|
+
return bool(self.meta and self.meta.get("label"))
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def label(self) -> typing.Optional[str]:
|
|
206
|
+
"""Template label from meta field."""
|
|
207
|
+
return (self.meta or {}).get("label")
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def is_default(self) -> bool:
|
|
211
|
+
"""Whether this is the default template."""
|
|
212
|
+
return (self.meta or {}).get("is_default", False)
|
|
213
|
+
|
|
214
214
|
@property
|
|
215
215
|
def object_type(self):
|
|
216
216
|
return "address"
|
|
@@ -226,13 +226,6 @@ class Address(core.OwnedEntity):
|
|
|
226
226
|
|
|
227
227
|
return None
|
|
228
228
|
|
|
229
|
-
@property
|
|
230
|
-
def customs(self):
|
|
231
|
-
if hasattr(self, "duty_billing_address_customs"):
|
|
232
|
-
return self.duty_billing_address_customs
|
|
233
|
-
|
|
234
|
-
return None
|
|
235
|
-
|
|
236
229
|
@property
|
|
237
230
|
def order(self):
|
|
238
231
|
if hasattr(self, "shipper_order"):
|
|
@@ -247,10 +240,12 @@ class Address(core.OwnedEntity):
|
|
|
247
240
|
|
|
248
241
|
@core.register_model
|
|
249
242
|
class Parcel(core.OwnedEntity):
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
243
|
+
"""Standalone parcel model - used for parcel templates only.
|
|
244
|
+
|
|
245
|
+
Parcels attached to shipments are stored as JSON in Shipment.parcels field.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
HIDDEN_PROPS = (*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),)
|
|
254
249
|
objects = ParcelManager()
|
|
255
250
|
|
|
256
251
|
class Meta:
|
|
@@ -292,17 +287,49 @@ class Parcel(core.OwnedEntity):
|
|
|
292
287
|
blank=True, null=True, default=functools.partial(utils.identity, value={})
|
|
293
288
|
)
|
|
294
289
|
|
|
290
|
+
# Template metadata: enables Parcel to serve as a reusable template
|
|
291
|
+
# Structure: {"label": "Standard Box", "is_default": true}
|
|
292
|
+
meta = models.JSONField(
|
|
293
|
+
blank=True,
|
|
294
|
+
null=True,
|
|
295
|
+
default=functools.partial(utils.identity, value={}),
|
|
296
|
+
help_text="Template metadata: label, is_default",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Metafields via GenericRelation
|
|
300
|
+
metafields = GenericRelation(
|
|
301
|
+
"core.Metafield",
|
|
302
|
+
related_query_name="parcel",
|
|
303
|
+
)
|
|
304
|
+
|
|
295
305
|
def delete(self, *args, **kwargs):
|
|
296
306
|
self.items.all().delete()
|
|
297
307
|
return super().delete(*args, **kwargs)
|
|
298
308
|
|
|
309
|
+
@property
|
|
310
|
+
def is_template(self) -> bool:
|
|
311
|
+
"""Check if this parcel is a template (has meta with label)."""
|
|
312
|
+
return bool(self.meta and self.meta.get("label"))
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def label(self) -> typing.Optional[str]:
|
|
316
|
+
"""Template label from meta field."""
|
|
317
|
+
return (self.meta or {}).get("label")
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def is_default(self) -> bool:
|
|
321
|
+
"""Whether this is the default template."""
|
|
322
|
+
return (self.meta or {}).get("is_default", False)
|
|
323
|
+
|
|
299
324
|
@property
|
|
300
325
|
def object_type(self):
|
|
301
326
|
return "parcel"
|
|
302
327
|
|
|
303
328
|
@property
|
|
304
329
|
def shipment(self):
|
|
305
|
-
|
|
330
|
+
# Standalone parcels (templates) don't have a shipment relationship
|
|
331
|
+
# Parcels attached to shipments are stored as JSON in Shipment.parcels
|
|
332
|
+
return None
|
|
306
333
|
|
|
307
334
|
|
|
308
335
|
@core.register_model
|
|
@@ -310,7 +337,6 @@ class Commodity(core.OwnedEntity):
|
|
|
310
337
|
HIDDEN_PROPS = (
|
|
311
338
|
"children",
|
|
312
339
|
"commodity_parcel",
|
|
313
|
-
"commodity_customs",
|
|
314
340
|
*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
|
|
315
341
|
)
|
|
316
342
|
objects = CommodityManager()
|
|
@@ -363,10 +389,34 @@ class Commodity(core.OwnedEntity):
|
|
|
363
389
|
blank=True, null=True, default=functools.partial(utils.identity, value={})
|
|
364
390
|
)
|
|
365
391
|
|
|
392
|
+
# Template metadata: enables Commodity to serve as a reusable template (product)
|
|
393
|
+
# Structure: {"label": "Widget Pro", "is_default": false}
|
|
394
|
+
meta = models.JSONField(
|
|
395
|
+
blank=True,
|
|
396
|
+
null=True,
|
|
397
|
+
default=functools.partial(utils.identity, value={}),
|
|
398
|
+
help_text="Template metadata: label, is_default",
|
|
399
|
+
)
|
|
400
|
+
|
|
366
401
|
def delete(self, *args, **kwargs):
|
|
367
402
|
self.children.all().delete()
|
|
368
403
|
return super().delete(*args, **kwargs)
|
|
369
404
|
|
|
405
|
+
@property
|
|
406
|
+
def is_template(self) -> bool:
|
|
407
|
+
"""Check if this commodity is a template/product (has meta with label)."""
|
|
408
|
+
return bool(self.meta and self.meta.get("label"))
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def label(self) -> typing.Optional[str]:
|
|
412
|
+
"""Template label from meta field."""
|
|
413
|
+
return (self.meta or {}).get("label")
|
|
414
|
+
|
|
415
|
+
@property
|
|
416
|
+
def is_default(self) -> bool:
|
|
417
|
+
"""Whether this is the default template."""
|
|
418
|
+
return (self.meta or {}).get("is_default", False)
|
|
419
|
+
|
|
370
420
|
@property
|
|
371
421
|
def object_type(self):
|
|
372
422
|
return "commodity"
|
|
@@ -375,15 +425,9 @@ class Commodity(core.OwnedEntity):
|
|
|
375
425
|
def parcel(self):
|
|
376
426
|
return self.commodity_parcel.first()
|
|
377
427
|
|
|
378
|
-
@property
|
|
379
|
-
def customs(self):
|
|
380
|
-
return self.commodity_customs.first()
|
|
381
|
-
|
|
382
428
|
@property
|
|
383
429
|
def shipment(self):
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
return getattr(related, "shipment", None)
|
|
430
|
+
return getattr(self.parcel, "shipment", None)
|
|
387
431
|
|
|
388
432
|
@property
|
|
389
433
|
def order(self):
|
|
@@ -395,92 +439,27 @@ class Commodity(core.OwnedEntity):
|
|
|
395
439
|
return None
|
|
396
440
|
|
|
397
441
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
DIRECT_PROPS = [
|
|
401
|
-
"content_description",
|
|
402
|
-
"content_type",
|
|
403
|
-
"incoterm",
|
|
404
|
-
"commercial_invoice",
|
|
405
|
-
"certify",
|
|
406
|
-
"duty",
|
|
407
|
-
"created_by",
|
|
408
|
-
"signer",
|
|
409
|
-
"invoice",
|
|
410
|
-
"invoice_date",
|
|
411
|
-
"options",
|
|
412
|
-
]
|
|
413
|
-
RELATIONAL_PROPS = ["commodities", "duty_billing_address"]
|
|
414
|
-
HIDDEN_PROPS = (
|
|
415
|
-
"customs_shipment",
|
|
416
|
-
*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
|
|
417
|
-
)
|
|
418
|
-
objects = CustomsManager()
|
|
419
|
-
|
|
420
|
-
class Meta:
|
|
421
|
-
db_table = "customs"
|
|
422
|
-
verbose_name = "Customs Info"
|
|
423
|
-
verbose_name_plural = "Customs Info"
|
|
424
|
-
ordering = ["-created_at"]
|
|
425
|
-
|
|
426
|
-
id = models.CharField(
|
|
427
|
-
max_length=50,
|
|
428
|
-
primary_key=True,
|
|
429
|
-
default=functools.partial(core.uuid, prefix="cst_"),
|
|
430
|
-
editable=False,
|
|
431
|
-
)
|
|
442
|
+
class Product(Commodity):
|
|
443
|
+
"""Product is a proxy model for Commodity, used for template/product API endpoints.
|
|
432
444
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
max_length=100, null=True, blank=True, db_index=True
|
|
437
|
-
)
|
|
438
|
-
content_description = models.CharField(max_length=250, null=True, blank=True)
|
|
439
|
-
incoterm = models.CharField(
|
|
440
|
-
max_length=50, choices=serializers.INCOTERMS, db_index=True
|
|
441
|
-
)
|
|
442
|
-
invoice = models.CharField(max_length=100, null=True, blank=True)
|
|
443
|
-
invoice_date = models.CharField(max_length=100, null=True, blank=True)
|
|
444
|
-
signer = models.CharField(max_length=100, null=True, blank=True)
|
|
445
|
+
This provides a cleaner "Product" naming for the template API while using
|
|
446
|
+
the same underlying Commodity table.
|
|
447
|
+
"""
|
|
445
448
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
blank=True, null=True, default=functools.partial(utils.identity, value={})
|
|
451
|
-
)
|
|
452
|
-
|
|
453
|
-
# System Reference fields
|
|
454
|
-
|
|
455
|
-
commodities = models.ManyToManyField(
|
|
456
|
-
"Commodity", blank=True, related_name="commodity_customs"
|
|
457
|
-
)
|
|
458
|
-
duty_billing_address = models.OneToOneField(
|
|
459
|
-
"Address",
|
|
460
|
-
null=True,
|
|
461
|
-
on_delete=models.SET_NULL,
|
|
462
|
-
related_name="duty_billing_address_customs",
|
|
463
|
-
)
|
|
464
|
-
|
|
465
|
-
def delete(self, *args, **kwargs):
|
|
466
|
-
self.commodities.all().delete()
|
|
467
|
-
return super().delete(*args, **kwargs)
|
|
449
|
+
class Meta:
|
|
450
|
+
proxy = True
|
|
451
|
+
verbose_name = "Product"
|
|
452
|
+
verbose_name_plural = "Products"
|
|
468
453
|
|
|
469
454
|
@property
|
|
470
455
|
def object_type(self):
|
|
471
|
-
return "
|
|
472
|
-
|
|
473
|
-
@property
|
|
474
|
-
def shipment(self):
|
|
475
|
-
if hasattr(self, "customs_shipment"):
|
|
476
|
-
return self.customs_shipment
|
|
477
|
-
|
|
478
|
-
return None
|
|
456
|
+
return "product"
|
|
479
457
|
|
|
480
458
|
|
|
481
459
|
@core.register_model
|
|
482
460
|
class Pickup(core.OwnedEntity):
|
|
483
|
-
|
|
461
|
+
"""Pickup model with embedded JSON address and carrier snapshot."""
|
|
462
|
+
|
|
484
463
|
DIRECT_PROPS = [
|
|
485
464
|
"confirmation_number",
|
|
486
465
|
"pickup_date",
|
|
@@ -493,11 +472,13 @@ class Pickup(core.OwnedEntity):
|
|
|
493
472
|
"created_by",
|
|
494
473
|
"metadata",
|
|
495
474
|
"meta",
|
|
475
|
+
"address", # Embedded JSON field
|
|
476
|
+
"carrier", # Carrier snapshot
|
|
496
477
|
]
|
|
497
478
|
objects = PickupManager()
|
|
498
479
|
|
|
499
480
|
class Meta:
|
|
500
|
-
db_table = "
|
|
481
|
+
db_table = "pickups"
|
|
501
482
|
verbose_name = "Pickup"
|
|
502
483
|
verbose_name_plural = "Pickups"
|
|
503
484
|
ordering = ["-created_at"]
|
|
@@ -516,17 +497,30 @@ class Pickup(core.OwnedEntity):
|
|
|
516
497
|
instruction = models.CharField(max_length=250, null=True, blank=True)
|
|
517
498
|
package_location = models.CharField(max_length=250, null=True, blank=True)
|
|
518
499
|
|
|
519
|
-
|
|
520
|
-
|
|
500
|
+
# ─────────────────────────────────────────────────────────────────
|
|
501
|
+
# EMBEDDED JSON FIELDS
|
|
502
|
+
# ─────────────────────────────────────────────────────────────────
|
|
503
|
+
address = models.JSONField(
|
|
504
|
+
blank=True,
|
|
505
|
+
null=True,
|
|
506
|
+
help_text="Pickup address (embedded JSON)",
|
|
521
507
|
)
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
related_name="address_pickup",
|
|
508
|
+
|
|
509
|
+
# Carrier snapshot - replaces pickup_carrier FK
|
|
510
|
+
# Structure: {connection_id, connection_type, carrier_code, carrier_id, carrier_name, test_mode}
|
|
511
|
+
carrier = models.JSONField(
|
|
527
512
|
blank=True,
|
|
528
513
|
null=True,
|
|
514
|
+
help_text="Carrier snapshot at time of pickup creation",
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# ─────────────────────────────────────────────────────────────────
|
|
518
|
+
# OPERATIONAL JSON FIELDS
|
|
519
|
+
# ─────────────────────────────────────────────────────────────────
|
|
520
|
+
options = models.JSONField(
|
|
521
|
+
blank=True, null=True, default=functools.partial(utils.identity, value={})
|
|
529
522
|
)
|
|
523
|
+
pickup_charge = models.JSONField(blank=True, null=True)
|
|
530
524
|
metadata = models.JSONField(
|
|
531
525
|
blank=True, null=True, default=functools.partial(utils.identity, value={})
|
|
532
526
|
)
|
|
@@ -534,59 +528,84 @@ class Pickup(core.OwnedEntity):
|
|
|
534
528
|
blank=True, default=functools.partial(utils.identity, value={})
|
|
535
529
|
)
|
|
536
530
|
|
|
537
|
-
#
|
|
538
|
-
|
|
539
|
-
|
|
531
|
+
# ─────────────────────────────────────────────────────────────────
|
|
532
|
+
# SHIPMENT RELATION (kept - operational necessity)
|
|
533
|
+
# ─────────────────────────────────────────────────────────────────
|
|
540
534
|
shipments = models.ManyToManyField("Shipment", related_name="shipment_pickup")
|
|
541
535
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
def resolve_context_data(cls, queryset, context):
|
|
548
|
-
"""Apply context-aware carrier config resolution for pickup_carrier."""
|
|
549
|
-
from karrio.server.providers.models.carrier import Carrier
|
|
536
|
+
# Metafields via GenericRelation
|
|
537
|
+
metafields = GenericRelation(
|
|
538
|
+
"core.Metafield",
|
|
539
|
+
related_query_name="pickup",
|
|
540
|
+
)
|
|
550
541
|
|
|
551
|
-
|
|
552
|
-
return
|
|
553
|
-
models.Prefetch("pickup_carrier", queryset=carrier_queryset),
|
|
554
|
-
)
|
|
542
|
+
def delete(self, *args, **kwargs):
|
|
543
|
+
return super().delete(*args, **kwargs)
|
|
555
544
|
|
|
556
545
|
@property
|
|
557
546
|
def object_type(self):
|
|
558
547
|
return "pickup"
|
|
559
548
|
|
|
560
|
-
# Computed properties
|
|
549
|
+
# Computed properties from carrier snapshot
|
|
550
|
+
|
|
551
|
+
@property
|
|
552
|
+
def carrier_id(self) -> typing.Optional[str]:
|
|
553
|
+
if self.carrier is None:
|
|
554
|
+
return None
|
|
555
|
+
return self.carrier.get("carrier_id")
|
|
561
556
|
|
|
562
557
|
@property
|
|
563
|
-
def
|
|
564
|
-
|
|
558
|
+
def carrier_name(self) -> typing.Optional[str]:
|
|
559
|
+
if self.carrier is None:
|
|
560
|
+
return None
|
|
561
|
+
return self.carrier.get("carrier_name")
|
|
565
562
|
|
|
566
563
|
@property
|
|
567
|
-
def
|
|
568
|
-
|
|
564
|
+
def carrier_code(self) -> typing.Optional[str]:
|
|
565
|
+
if self.carrier is None:
|
|
566
|
+
return None
|
|
567
|
+
return self.carrier.get("carrier_code")
|
|
569
568
|
|
|
570
569
|
@property
|
|
571
|
-
def parcels(self) -> typing.List[
|
|
570
|
+
def parcels(self) -> typing.List[dict]:
|
|
571
|
+
"""Get all parcels from related shipments as JSON data."""
|
|
572
|
+
if self.pk is None:
|
|
573
|
+
return []
|
|
572
574
|
return sum(
|
|
573
|
-
[
|
|
575
|
+
[shipment.parcels or [] for shipment in self.shipments.all()], []
|
|
574
576
|
)
|
|
575
577
|
|
|
576
578
|
@property
|
|
577
579
|
def tracking_numbers(self) -> typing.List[str]:
|
|
580
|
+
if self.pk is None:
|
|
581
|
+
return []
|
|
578
582
|
return [shipment.tracking_number for shipment in self.shipments.all()]
|
|
579
583
|
|
|
584
|
+
@property
|
|
585
|
+
def pickup_type(self) -> typing.Optional[str]:
|
|
586
|
+
"""Get pickup type from meta field (one_time, daily, recurring)."""
|
|
587
|
+
if self.meta is None:
|
|
588
|
+
return "one_time"
|
|
589
|
+
return self.meta.get("pickup_type", "one_time")
|
|
590
|
+
|
|
591
|
+
@property
|
|
592
|
+
def recurrence(self) -> typing.Optional[dict]:
|
|
593
|
+
"""Get recurrence config from meta field."""
|
|
594
|
+
if self.meta is None:
|
|
595
|
+
return None
|
|
596
|
+
return self.meta.get("recurrence")
|
|
597
|
+
|
|
580
598
|
|
|
581
599
|
@core.register_model
|
|
582
600
|
class Tracking(core.OwnedEntity):
|
|
583
|
-
|
|
601
|
+
"""Tracking model with embedded carrier snapshot."""
|
|
602
|
+
|
|
584
603
|
DIRECT_PROPS = [
|
|
585
604
|
"metadata",
|
|
586
605
|
"info",
|
|
606
|
+
"carrier", # Carrier snapshot
|
|
587
607
|
]
|
|
588
608
|
HIDDEN_PROPS = (
|
|
589
|
-
"tracking_carrier",
|
|
590
609
|
*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
|
|
591
610
|
)
|
|
592
611
|
objects = TrackingManager()
|
|
@@ -597,8 +616,18 @@ class Tracking(core.OwnedEntity):
|
|
|
597
616
|
verbose_name_plural = "Tracking Statuses"
|
|
598
617
|
ordering = ["-created_at"]
|
|
599
618
|
indexes = [
|
|
600
|
-
# Index for archiving queries based on creation date
|
|
601
619
|
models.Index(fields=["created_at"], name="tracking_created_at_idx"),
|
|
620
|
+
# JSONField indexes for carrier snapshot queries
|
|
621
|
+
models.Index(
|
|
622
|
+
fields.json.KeyTextTransform("carrier_code", "carrier"),
|
|
623
|
+
condition=models.Q(carrier__isnull=False),
|
|
624
|
+
name="tracking_carrier_code_idx",
|
|
625
|
+
),
|
|
626
|
+
models.Index(
|
|
627
|
+
fields.json.KeyTextTransform("connection_id", "carrier"),
|
|
628
|
+
condition=models.Q(carrier__isnull=False),
|
|
629
|
+
name="tracking_connection_id_idx",
|
|
630
|
+
),
|
|
602
631
|
]
|
|
603
632
|
|
|
604
633
|
id = models.CharField(
|
|
@@ -642,36 +671,52 @@ class Tracking(core.OwnedEntity):
|
|
|
642
671
|
delivery_image = models.TextField(max_length=None, null=True, blank=True)
|
|
643
672
|
signature_image = models.TextField(max_length=None, null=True, blank=True)
|
|
644
673
|
|
|
645
|
-
#
|
|
674
|
+
# ─────────────────────────────────────────────────────────────────
|
|
675
|
+
# CARRIER SNAPSHOT (replaces tracking_carrier FK)
|
|
676
|
+
# ─────────────────────────────────────────────────────────────────
|
|
677
|
+
# Structure: {connection_id, connection_type, carrier_code, carrier_id, carrier_name, test_mode}
|
|
678
|
+
carrier = models.JSONField(
|
|
679
|
+
blank=True,
|
|
680
|
+
null=True,
|
|
681
|
+
help_text="Carrier snapshot at time of tracker creation",
|
|
682
|
+
)
|
|
646
683
|
|
|
647
|
-
|
|
684
|
+
# ─────────────────────────────────────────────────────────────────
|
|
685
|
+
# SHIPMENT RELATION (kept - operational necessity)
|
|
686
|
+
# ─────────────────────────────────────────────────────────────────
|
|
648
687
|
shipment = models.OneToOneField(
|
|
649
688
|
"Shipment", on_delete=models.CASCADE, related_name="shipment_tracker", null=True
|
|
650
689
|
)
|
|
651
690
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
"
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
carrier_queryset = Carrier.objects.resolve_config_for(context)
|
|
658
|
-
return queryset.prefetch_related(
|
|
659
|
-
models.Prefetch("tracking_carrier", queryset=carrier_queryset),
|
|
660
|
-
)
|
|
691
|
+
# Metafields via GenericRelation
|
|
692
|
+
metafields = GenericRelation(
|
|
693
|
+
"core.Metafield",
|
|
694
|
+
related_query_name="tracking",
|
|
695
|
+
)
|
|
661
696
|
|
|
662
697
|
@property
|
|
663
698
|
def object_type(self):
|
|
664
699
|
return "tracker"
|
|
665
700
|
|
|
666
|
-
# Computed properties
|
|
701
|
+
# Computed properties from carrier snapshot
|
|
702
|
+
|
|
703
|
+
@property
|
|
704
|
+
def carrier_id(self) -> typing.Optional[str]:
|
|
705
|
+
if self.carrier is None:
|
|
706
|
+
return None
|
|
707
|
+
return self.carrier.get("carrier_id")
|
|
667
708
|
|
|
668
709
|
@property
|
|
669
|
-
def
|
|
670
|
-
|
|
710
|
+
def carrier_name(self) -> typing.Optional[str]:
|
|
711
|
+
if self.carrier is None:
|
|
712
|
+
return None
|
|
713
|
+
return self.carrier.get("carrier_name")
|
|
671
714
|
|
|
672
715
|
@property
|
|
673
|
-
def
|
|
674
|
-
|
|
716
|
+
def carrier_code(self) -> typing.Optional[str]:
|
|
717
|
+
if self.carrier is None:
|
|
718
|
+
return None
|
|
719
|
+
return self.carrier.get("carrier_code")
|
|
675
720
|
|
|
676
721
|
@property
|
|
677
722
|
def pending(self) -> bool:
|
|
@@ -702,10 +747,12 @@ class Tracking(core.OwnedEntity):
|
|
|
702
747
|
|
|
703
748
|
@core.register_model
|
|
704
749
|
class Shipment(core.OwnedEntity):
|
|
705
|
-
|
|
750
|
+
"""Shipment model with embedded JSON data for addresses, parcels, customs, and carrier."""
|
|
751
|
+
|
|
706
752
|
DIRECT_PROPS = [
|
|
707
753
|
"options",
|
|
708
754
|
"services",
|
|
755
|
+
"carrier_ids",
|
|
709
756
|
"status",
|
|
710
757
|
"meta",
|
|
711
758
|
"label_type",
|
|
@@ -719,29 +766,28 @@ class Shipment(core.OwnedEntity):
|
|
|
719
766
|
"metadata",
|
|
720
767
|
"created_by",
|
|
721
768
|
"reference",
|
|
722
|
-
|
|
723
|
-
|
|
769
|
+
"applied_fees", # Accounting: addons + surcharge COGS values
|
|
770
|
+
# Embedded JSON fields
|
|
724
771
|
"shipper",
|
|
725
772
|
"recipient",
|
|
726
773
|
"parcels",
|
|
727
774
|
"customs",
|
|
728
|
-
"selected_rate",
|
|
729
775
|
"return_address",
|
|
730
776
|
"billing_address",
|
|
777
|
+
"selected_rate",
|
|
778
|
+
"carrier", # Carrier snapshot
|
|
731
779
|
]
|
|
732
780
|
HIDDEN_PROPS = (
|
|
733
|
-
"carriers",
|
|
734
781
|
"label",
|
|
735
782
|
"invoice",
|
|
736
783
|
"shipment_pickup",
|
|
737
784
|
"shipment_tracker",
|
|
738
|
-
"selected_rate_carrier",
|
|
739
785
|
*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
|
|
740
786
|
)
|
|
741
787
|
objects = ShipmentManager()
|
|
742
788
|
|
|
743
789
|
class Meta:
|
|
744
|
-
db_table = "
|
|
790
|
+
db_table = "shipments"
|
|
745
791
|
verbose_name = "Shipment"
|
|
746
792
|
verbose_name_plural = "Shipments"
|
|
747
793
|
ordering = ["-created_at"]
|
|
@@ -751,8 +797,13 @@ class Shipment(core.OwnedEntity):
|
|
|
751
797
|
condition=models.Q(meta__object_id__isnull=False),
|
|
752
798
|
name="shipment_service_idx",
|
|
753
799
|
),
|
|
754
|
-
# Index for archiving queries based on creation date
|
|
755
800
|
models.Index(fields=["created_at"], name="shipment_created_at_idx"),
|
|
801
|
+
# JSONField index for carrier snapshot queries
|
|
802
|
+
models.Index(
|
|
803
|
+
fields.json.KeyTextTransform("carrier_code", "carrier"),
|
|
804
|
+
condition=models.Q(carrier__isnull=False),
|
|
805
|
+
name="shipment_carrier_code_idx",
|
|
806
|
+
),
|
|
756
807
|
]
|
|
757
808
|
|
|
758
809
|
id = models.CharField(
|
|
@@ -767,44 +818,63 @@ class Shipment(core.OwnedEntity):
|
|
|
767
818
|
default=serializers.SHIPMENT_STATUS[0][0],
|
|
768
819
|
db_index=True,
|
|
769
820
|
)
|
|
821
|
+
label_type = models.CharField(max_length=25, null=True, blank=True)
|
|
822
|
+
tracking_number = models.CharField(
|
|
823
|
+
max_length=100, null=True, blank=True, db_index=True
|
|
824
|
+
)
|
|
825
|
+
shipment_identifier = models.CharField(max_length=100, null=True, blank=True)
|
|
826
|
+
tracking_url = models.TextField(max_length=None, null=True, blank=True)
|
|
827
|
+
test_mode = models.BooleanField(null=False)
|
|
828
|
+
reference = models.CharField(max_length=100, null=True, blank=True)
|
|
770
829
|
|
|
771
|
-
|
|
772
|
-
|
|
830
|
+
# Document storage
|
|
831
|
+
label = models.TextField(max_length=None, null=True, blank=True)
|
|
832
|
+
invoice = models.TextField(max_length=None, null=True, blank=True)
|
|
833
|
+
|
|
834
|
+
# ─────────────────────────────────────────────────────────────────
|
|
835
|
+
# EMBEDDED JSON FIELDS
|
|
836
|
+
# ─────────────────────────────────────────────────────────────────
|
|
837
|
+
shipper = models.JSONField(
|
|
838
|
+
blank=True,
|
|
839
|
+
null=True,
|
|
840
|
+
help_text="Shipper address (embedded JSON)",
|
|
773
841
|
)
|
|
774
|
-
|
|
775
|
-
|
|
842
|
+
recipient = models.JSONField(
|
|
843
|
+
blank=True,
|
|
844
|
+
null=True,
|
|
845
|
+
help_text="Recipient address (embedded JSON)",
|
|
776
846
|
)
|
|
777
|
-
return_address = models.
|
|
778
|
-
|
|
847
|
+
return_address = models.JSONField(
|
|
848
|
+
blank=True,
|
|
779
849
|
null=True,
|
|
780
|
-
|
|
781
|
-
related_name="return_address_shipment",
|
|
850
|
+
help_text="Return address (embedded JSON)",
|
|
782
851
|
)
|
|
783
|
-
billing_address = models.
|
|
784
|
-
|
|
852
|
+
billing_address = models.JSONField(
|
|
853
|
+
blank=True,
|
|
785
854
|
null=True,
|
|
786
|
-
|
|
787
|
-
related_name="billing_address_shipment",
|
|
855
|
+
help_text="Billing address (embedded JSON)",
|
|
788
856
|
)
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
857
|
+
parcels = models.JSONField(
|
|
858
|
+
blank=True,
|
|
859
|
+
null=True,
|
|
860
|
+
default=functools.partial(utils.identity, value=[]),
|
|
861
|
+
help_text="Parcels array with nested items (embedded JSON)",
|
|
792
862
|
)
|
|
793
|
-
|
|
794
|
-
tracking_url = models.TextField(max_length=None, null=True, blank=True)
|
|
795
|
-
test_mode = models.BooleanField(null=False)
|
|
796
|
-
customs = models.OneToOneField(
|
|
797
|
-
"Customs",
|
|
798
|
-
on_delete=models.SET_NULL,
|
|
863
|
+
customs = models.JSONField(
|
|
799
864
|
blank=True,
|
|
800
865
|
null=True,
|
|
801
|
-
|
|
866
|
+
help_text="Customs information (embedded JSON)",
|
|
802
867
|
)
|
|
803
868
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
869
|
+
# ─────────────────────────────────────────────────────────────────
|
|
870
|
+
# OPERATIONAL JSON FIELDS
|
|
871
|
+
# ─────────────────────────────────────────────────────────────────
|
|
872
|
+
# selected_rate contains rate details:
|
|
873
|
+
# {id, carrier_id, carrier_name, service, currency, total_charge, test_mode, meta: {...}}
|
|
807
874
|
selected_rate = models.JSONField(blank=True, null=True)
|
|
875
|
+
rates = models.JSONField(
|
|
876
|
+
blank=True, null=True, default=functools.partial(utils.identity, value=[])
|
|
877
|
+
)
|
|
808
878
|
payment = models.JSONField(
|
|
809
879
|
blank=True, null=True, default=functools.partial(utils.identity, value=None)
|
|
810
880
|
)
|
|
@@ -814,6 +884,10 @@ class Shipment(core.OwnedEntity):
|
|
|
814
884
|
services = models.JSONField(
|
|
815
885
|
blank=True, null=True, default=functools.partial(utils.identity, value=[])
|
|
816
886
|
)
|
|
887
|
+
carrier_ids = models.JSONField(
|
|
888
|
+
blank=True, null=True, default=functools.partial(utils.identity, value=[]),
|
|
889
|
+
help_text="List of carrier IDs to filter rate requests",
|
|
890
|
+
)
|
|
817
891
|
messages = models.JSONField(
|
|
818
892
|
blank=True, null=True, default=functools.partial(utils.identity, value=[])
|
|
819
893
|
)
|
|
@@ -826,23 +900,26 @@ class Shipment(core.OwnedEntity):
|
|
|
826
900
|
extra_documents = models.JSONField(
|
|
827
901
|
blank=True, null=True, default=functools.partial(utils.identity, value=[])
|
|
828
902
|
)
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
)
|
|
835
|
-
parcels = models.ManyToManyField("Parcel", related_name="parcel_shipment")
|
|
836
|
-
carriers = models.ManyToManyField(
|
|
837
|
-
providers.Carrier, blank=True, related_name="related_shipments"
|
|
903
|
+
applied_fees = models.JSONField(
|
|
904
|
+
blank=True,
|
|
905
|
+
null=True,
|
|
906
|
+
default=functools.partial(utils.identity, value=[]),
|
|
907
|
+
help_text="Applied fees for accounting: addons + surcharge COGS values",
|
|
838
908
|
)
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
909
|
+
|
|
910
|
+
# ─────────────────────────────────────────────────────────────────
|
|
911
|
+
# CARRIER SNAPSHOT (consistent with Tracking, Pickup, Manifest, DocumentUploadRecord)
|
|
912
|
+
# ─────────────────────────────────────────────────────────────────
|
|
913
|
+
# Structure: {connection_id, connection_type, carrier_code, carrier_id, carrier_name, test_mode}
|
|
914
|
+
carrier = models.JSONField(
|
|
843
915
|
blank=True,
|
|
844
916
|
null=True,
|
|
917
|
+
help_text="Carrier snapshot at time of label purchase",
|
|
845
918
|
)
|
|
919
|
+
|
|
920
|
+
# ─────────────────────────────────────────────────────────────────
|
|
921
|
+
# MANIFEST RELATION (kept - operational necessity)
|
|
922
|
+
# ─────────────────────────────────────────────────────────────────
|
|
846
923
|
manifest = models.ForeignKey(
|
|
847
924
|
"Manifest",
|
|
848
925
|
on_delete=models.SET_NULL,
|
|
@@ -851,77 +928,63 @@ class Shipment(core.OwnedEntity):
|
|
|
851
928
|
null=True,
|
|
852
929
|
)
|
|
853
930
|
|
|
931
|
+
# Metafields via GenericRelation
|
|
932
|
+
metafields = GenericRelation(
|
|
933
|
+
"core.Metafield",
|
|
934
|
+
related_query_name="shipment",
|
|
935
|
+
)
|
|
936
|
+
|
|
854
937
|
def delete(self, *args, **kwargs):
|
|
855
|
-
self.parcels.all().delete()
|
|
856
|
-
self.customs and self.customs.delete()
|
|
857
938
|
return super().delete(*args, **kwargs)
|
|
858
939
|
|
|
859
|
-
@classmethod
|
|
860
|
-
def resolve_context_data(cls, queryset, context):
|
|
861
|
-
"""
|
|
862
|
-
Apply context-aware prefetching for carriers with proper config resolution.
|
|
863
|
-
This is called by access_by() to ensure carrier configs are resolved for the request context.
|
|
864
|
-
"""
|
|
865
|
-
from karrio.server.providers.models.carrier import Carrier
|
|
866
|
-
|
|
867
|
-
# Resolve carrier configs with the request context for user/org-specific config
|
|
868
|
-
carrier_queryset = Carrier.objects.resolve_config_for(context)
|
|
869
|
-
|
|
870
|
-
# Re-apply carrier prefetches with context-aware config resolution
|
|
871
|
-
# Note: Manager's get_queryset() already sets up base prefetches with context=None
|
|
872
|
-
# This overrides those prefetches with context-aware ones when called via access_by()
|
|
873
|
-
return queryset.prefetch_related(
|
|
874
|
-
models.Prefetch("carriers", queryset=carrier_queryset),
|
|
875
|
-
models.Prefetch("selected_rate_carrier", queryset=carrier_queryset),
|
|
876
|
-
)
|
|
877
|
-
|
|
878
940
|
@property
|
|
879
941
|
def object_type(self):
|
|
880
942
|
return "shipment"
|
|
881
943
|
|
|
882
|
-
# Computed properties
|
|
944
|
+
# Computed properties from carrier snapshot
|
|
883
945
|
|
|
884
946
|
@property
|
|
885
|
-
def carrier_id(self) -> str:
|
|
886
|
-
|
|
947
|
+
def carrier_id(self) -> typing.Optional[str]:
|
|
948
|
+
if self.carrier is None:
|
|
949
|
+
return None
|
|
950
|
+
return self.carrier.get("carrier_id")
|
|
887
951
|
|
|
888
952
|
@property
|
|
889
|
-
def carrier_name(self) -> str:
|
|
890
|
-
|
|
953
|
+
def carrier_name(self) -> typing.Optional[str]:
|
|
954
|
+
if self.carrier is None:
|
|
955
|
+
return None
|
|
956
|
+
return self.carrier.get("carrier_name")
|
|
891
957
|
|
|
892
958
|
@property
|
|
893
|
-
def
|
|
894
|
-
|
|
959
|
+
def carrier_code(self) -> typing.Optional[str]:
|
|
960
|
+
if self.carrier is None:
|
|
961
|
+
return None
|
|
962
|
+
return self.carrier.get("carrier_code")
|
|
895
963
|
|
|
896
964
|
@property
|
|
897
|
-
def
|
|
898
|
-
return
|
|
965
|
+
def tracker_id(self) -> typing.Optional[str]:
|
|
966
|
+
return getattr(self.tracker, "id", None)
|
|
899
967
|
|
|
900
968
|
@property
|
|
901
|
-
def selected_rate_id(self) -> str:
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
else None
|
|
906
|
-
)
|
|
969
|
+
def selected_rate_id(self) -> typing.Optional[str]:
|
|
970
|
+
if self.selected_rate is None:
|
|
971
|
+
return None
|
|
972
|
+
return self.selected_rate.get("id")
|
|
907
973
|
|
|
908
974
|
@property
|
|
909
|
-
def service(self) -> str:
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
else None
|
|
914
|
-
)
|
|
975
|
+
def service(self) -> typing.Optional[str]:
|
|
976
|
+
if self.selected_rate is None:
|
|
977
|
+
return None
|
|
978
|
+
return self.selected_rate.get("service")
|
|
915
979
|
|
|
916
980
|
@property
|
|
917
981
|
def tracker(self):
|
|
918
982
|
if hasattr(self, "shipment_tracker"):
|
|
919
983
|
return self.shipment_tracker
|
|
920
|
-
|
|
921
984
|
return None
|
|
922
985
|
|
|
923
986
|
@property
|
|
924
|
-
def label_url(self) -> str:
|
|
987
|
+
def label_url(self) -> typing.Optional[str]:
|
|
925
988
|
if self.label is None:
|
|
926
989
|
return None
|
|
927
990
|
|
|
@@ -933,7 +996,7 @@ class Shipment(core.OwnedEntity):
|
|
|
933
996
|
)
|
|
934
997
|
|
|
935
998
|
@property
|
|
936
|
-
def invoice_url(self) -> str:
|
|
999
|
+
def invoice_url(self) -> typing.Optional[str]:
|
|
937
1000
|
if self.invoice is None:
|
|
938
1001
|
return None
|
|
939
1002
|
|
|
@@ -949,12 +1012,19 @@ class Shipment(core.OwnedEntity):
|
|
|
949
1012
|
invoice=self.invoice,
|
|
950
1013
|
)
|
|
951
1014
|
|
|
952
|
-
|
|
953
1015
|
@core.register_model
|
|
954
1016
|
class DocumentUploadRecord(core.OwnedEntity):
|
|
955
|
-
|
|
1017
|
+
"""Document upload record with embedded carrier snapshot."""
|
|
1018
|
+
|
|
1019
|
+
DIRECT_PROPS = [
|
|
1020
|
+
"documents",
|
|
1021
|
+
"messages",
|
|
1022
|
+
"meta",
|
|
1023
|
+
"options",
|
|
1024
|
+
"reference",
|
|
1025
|
+
"carrier", # Carrier snapshot
|
|
1026
|
+
]
|
|
956
1027
|
HIDDEN_PROPS = (
|
|
957
|
-
"upload_carrier",
|
|
958
1028
|
*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
|
|
959
1029
|
)
|
|
960
1030
|
objects = DocumentUploadRecordManager()
|
|
@@ -985,38 +1055,50 @@ class DocumentUploadRecord(core.OwnedEntity):
|
|
|
985
1055
|
)
|
|
986
1056
|
reference = models.CharField(max_length=100, null=True, blank=True)
|
|
987
1057
|
|
|
988
|
-
#
|
|
1058
|
+
# ─────────────────────────────────────────────────────────────────
|
|
1059
|
+
# CARRIER SNAPSHOT (replaces upload_carrier FK)
|
|
1060
|
+
# ─────────────────────────────────────────────────────────────────
|
|
1061
|
+
# Structure: {connection_id, connection_type, carrier_code, carrier_id, carrier_name, test_mode}
|
|
1062
|
+
carrier = models.JSONField(
|
|
1063
|
+
blank=True,
|
|
1064
|
+
null=True,
|
|
1065
|
+
help_text="Carrier snapshot at time of document upload",
|
|
1066
|
+
)
|
|
989
1067
|
|
|
990
|
-
|
|
1068
|
+
# ─────────────────────────────────────────────────────────────────
|
|
1069
|
+
# SHIPMENT RELATION (kept - operational necessity)
|
|
1070
|
+
# ─────────────────────────────────────────────────────────────────
|
|
991
1071
|
shipment = models.OneToOneField(
|
|
992
1072
|
"Shipment",
|
|
993
1073
|
on_delete=models.CASCADE,
|
|
994
1074
|
related_name="shipment_upload_record",
|
|
995
1075
|
)
|
|
996
1076
|
|
|
997
|
-
|
|
998
|
-
def resolve_context_data(cls, queryset, context):
|
|
999
|
-
"""Apply context-aware carrier config resolution for upload_carrier."""
|
|
1000
|
-
from karrio.server.providers.models.carrier import Carrier
|
|
1077
|
+
# Computed properties from carrier snapshot
|
|
1001
1078
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
# Computed properties
|
|
1079
|
+
@property
|
|
1080
|
+
def carrier_id(self) -> typing.Optional[str]:
|
|
1081
|
+
if self.carrier is None:
|
|
1082
|
+
return None
|
|
1083
|
+
return self.carrier.get("carrier_id")
|
|
1008
1084
|
|
|
1009
1085
|
@property
|
|
1010
|
-
def
|
|
1011
|
-
|
|
1086
|
+
def carrier_name(self) -> typing.Optional[str]:
|
|
1087
|
+
if self.carrier is None:
|
|
1088
|
+
return None
|
|
1089
|
+
return self.carrier.get("carrier_name")
|
|
1012
1090
|
|
|
1013
1091
|
@property
|
|
1014
|
-
def
|
|
1015
|
-
|
|
1092
|
+
def carrier_code(self) -> typing.Optional[str]:
|
|
1093
|
+
if self.carrier is None:
|
|
1094
|
+
return None
|
|
1095
|
+
return self.carrier.get("carrier_code")
|
|
1016
1096
|
|
|
1017
1097
|
|
|
1018
1098
|
@core.register_model
|
|
1019
1099
|
class Manifest(core.OwnedEntity):
|
|
1100
|
+
"""Manifest model with embedded JSON address and carrier snapshot."""
|
|
1101
|
+
|
|
1020
1102
|
DIRECT_PROPS = [
|
|
1021
1103
|
"meta",
|
|
1022
1104
|
"options",
|
|
@@ -1024,23 +1106,27 @@ class Manifest(core.OwnedEntity):
|
|
|
1024
1106
|
"messages",
|
|
1025
1107
|
"created_by",
|
|
1026
1108
|
"reference",
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
"address",
|
|
1109
|
+
"address", # Embedded JSON field
|
|
1110
|
+
"carrier", # Carrier snapshot
|
|
1030
1111
|
]
|
|
1031
1112
|
HIDDEN_PROPS = (
|
|
1032
|
-
"manifest_carrier",
|
|
1033
1113
|
*(("org",) if conf.settings.MULTI_ORGANIZATIONS else tuple()),
|
|
1034
1114
|
)
|
|
1035
1115
|
objects = ManifestManager()
|
|
1036
1116
|
|
|
1037
1117
|
class Meta:
|
|
1038
|
-
db_table = "
|
|
1118
|
+
db_table = "manifests"
|
|
1039
1119
|
verbose_name = "Manifest"
|
|
1040
1120
|
verbose_name_plural = "Manifests"
|
|
1041
1121
|
ordering = ["-created_at"]
|
|
1042
|
-
|
|
1043
|
-
|
|
1122
|
+
indexes = [
|
|
1123
|
+
# JSONField index for carrier snapshot queries
|
|
1124
|
+
models.Index(
|
|
1125
|
+
fields.json.KeyTextTransform("carrier_code", "carrier"),
|
|
1126
|
+
condition=models.Q(carrier__isnull=False),
|
|
1127
|
+
name="manifest_carrier_code_idx",
|
|
1128
|
+
),
|
|
1129
|
+
]
|
|
1044
1130
|
|
|
1045
1131
|
id = models.CharField(
|
|
1046
1132
|
max_length=50,
|
|
@@ -1048,9 +1134,30 @@ class Manifest(core.OwnedEntity):
|
|
|
1048
1134
|
default=functools.partial(core.uuid, prefix="manf_"),
|
|
1049
1135
|
editable=False,
|
|
1050
1136
|
)
|
|
1051
|
-
|
|
1052
|
-
|
|
1137
|
+
reference = models.CharField(max_length=100, null=True, blank=True)
|
|
1138
|
+
manifest = models.TextField(max_length=None, null=True, blank=True)
|
|
1139
|
+
test_mode = models.BooleanField(null=False)
|
|
1140
|
+
|
|
1141
|
+
# ─────────────────────────────────────────────────────────────────
|
|
1142
|
+
# EMBEDDED JSON FIELDS
|
|
1143
|
+
# ─────────────────────────────────────────────────────────────────
|
|
1144
|
+
address = models.JSONField(
|
|
1145
|
+
blank=True,
|
|
1146
|
+
null=True,
|
|
1147
|
+
help_text="Manifest address (embedded JSON)",
|
|
1053
1148
|
)
|
|
1149
|
+
|
|
1150
|
+
# Carrier snapshot - replaces manifest_carrier FK
|
|
1151
|
+
# Structure: {connection_id, connection_type, carrier_code, carrier_id, carrier_name, test_mode}
|
|
1152
|
+
carrier = models.JSONField(
|
|
1153
|
+
blank=True,
|
|
1154
|
+
null=True,
|
|
1155
|
+
help_text="Carrier snapshot at time of manifest creation",
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
# ─────────────────────────────────────────────────────────────────
|
|
1159
|
+
# OPERATIONAL JSON FIELDS
|
|
1160
|
+
# ─────────────────────────────────────────────────────────────────
|
|
1054
1161
|
metadata = models.JSONField(
|
|
1055
1162
|
blank=True, null=True, default=functools.partial(utils.identity, value={})
|
|
1056
1163
|
)
|
|
@@ -1063,40 +1170,33 @@ class Manifest(core.OwnedEntity):
|
|
|
1063
1170
|
messages = models.JSONField(
|
|
1064
1171
|
blank=True, null=True, default=functools.partial(utils.identity, value=[])
|
|
1065
1172
|
)
|
|
1066
|
-
reference = models.CharField(max_length=100, null=True, blank=True)
|
|
1067
|
-
manifest = models.TextField(max_length=None, null=True, blank=True)
|
|
1068
|
-
test_mode = models.BooleanField(null=False)
|
|
1069
|
-
|
|
1070
|
-
# System Reference fields
|
|
1071
|
-
|
|
1072
|
-
manifest_carrier = models.ForeignKey(providers.Carrier, on_delete=models.CASCADE)
|
|
1073
1173
|
|
|
1074
|
-
|
|
1075
|
-
def resolve_context_data(cls, queryset, context):
|
|
1076
|
-
"""Apply context-aware carrier config resolution for manifest_carrier."""
|
|
1077
|
-
from karrio.server.providers.models.carrier import Carrier
|
|
1078
|
-
|
|
1079
|
-
carrier_queryset = Carrier.objects.resolve_config_for(context)
|
|
1080
|
-
return queryset.prefetch_related(
|
|
1081
|
-
models.Prefetch("manifest_carrier", queryset=carrier_queryset),
|
|
1082
|
-
)
|
|
1083
|
-
|
|
1084
|
-
# Computed properties
|
|
1174
|
+
# Computed properties from carrier snapshot
|
|
1085
1175
|
|
|
1086
1176
|
@property
|
|
1087
1177
|
def object_type(self):
|
|
1088
1178
|
return "manifest"
|
|
1089
1179
|
|
|
1090
1180
|
@property
|
|
1091
|
-
def carrier_id(self) -> str:
|
|
1092
|
-
|
|
1181
|
+
def carrier_id(self) -> typing.Optional[str]:
|
|
1182
|
+
if self.carrier is None:
|
|
1183
|
+
return None
|
|
1184
|
+
return self.carrier.get("carrier_id")
|
|
1185
|
+
|
|
1186
|
+
@property
|
|
1187
|
+
def carrier_name(self) -> typing.Optional[str]:
|
|
1188
|
+
if self.carrier is None:
|
|
1189
|
+
return None
|
|
1190
|
+
return self.carrier.get("carrier_name")
|
|
1093
1191
|
|
|
1094
1192
|
@property
|
|
1095
|
-
def
|
|
1096
|
-
|
|
1193
|
+
def carrier_code(self) -> typing.Optional[str]:
|
|
1194
|
+
if self.carrier is None:
|
|
1195
|
+
return None
|
|
1196
|
+
return self.carrier.get("carrier_code")
|
|
1097
1197
|
|
|
1098
1198
|
@property
|
|
1099
|
-
def manifest_url(self) -> str:
|
|
1199
|
+
def manifest_url(self) -> typing.Optional[str]:
|
|
1100
1200
|
if self.manifest is None:
|
|
1101
1201
|
return None
|
|
1102
1202
|
|