karrio-server-core 2025.5rc11__py3-none-any.whl → 2025.5rc13__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.

Potentially problematic release.


This version of karrio-server-core might be problematic. Click here for more details.

@@ -88,6 +88,7 @@ class CarrierDetails(serializers.Serializer):
88
88
  help_text="The carrier shipping options.",
89
89
  )
90
90
 
91
+
91
92
  class CarrierSettings(serializers.Serializer):
92
93
  id = serializers.CharField(required=True, help_text="A unique address identifier")
93
94
  object_type = serializers.CharField(
@@ -161,7 +162,7 @@ class AddressData(validators.AugmentedAddressSerializer):
161
162
  required=False,
162
163
  allow_blank=True,
163
164
  allow_null=True,
164
- max_length=30,
165
+ max_length=50,
165
166
  help_text="""The address city.
166
167
  **(required for shipment purchase)**
167
168
  """,
@@ -170,21 +171,21 @@ class AddressData(validators.AugmentedAddressSerializer):
170
171
  required=False,
171
172
  allow_blank=True,
172
173
  allow_null=True,
173
- max_length=20,
174
+ max_length=50,
174
175
  help_text="The party frederal tax id",
175
176
  )
176
177
  state_tax_id = serializers.CharField(
177
178
  required=False,
178
179
  allow_blank=True,
179
180
  allow_null=True,
180
- max_length=20,
181
+ max_length=50,
181
182
  help_text="The party state id",
182
183
  )
183
184
  person_name = serializers.CharField(
184
185
  required=False,
185
186
  allow_blank=True,
186
187
  allow_null=True,
187
- max_length=50,
188
+ max_length=100,
188
189
  help_text="""Attention to
189
190
  **(required for shipment purchase)**
190
191
  """,
@@ -193,7 +194,7 @@ class AddressData(validators.AugmentedAddressSerializer):
193
194
  required=False,
194
195
  allow_blank=True,
195
196
  allow_null=True,
196
- max_length=50,
197
+ max_length=100,
197
198
  help_text="The company name if the party is a company",
198
199
  )
199
200
  country_code = serializers.ChoiceField(
@@ -208,14 +209,14 @@ class AddressData(validators.AugmentedAddressSerializer):
208
209
  required=False,
209
210
  allow_blank=True,
210
211
  allow_null=True,
211
- max_length=20,
212
+ max_length=50,
212
213
  help_text="The party phone number.",
213
214
  )
214
215
  state_code = serializers.CharField(
215
216
  required=False,
216
217
  allow_blank=True,
217
218
  allow_null=True,
218
- max_length=20,
219
+ max_length=50,
219
220
  help_text="The address state code",
220
221
  )
221
222
  residential = serializers.BooleanField(
@@ -229,14 +230,14 @@ class AddressData(validators.AugmentedAddressSerializer):
229
230
  required=False,
230
231
  allow_blank=True,
231
232
  allow_null=True,
232
- max_length=20,
233
+ max_length=100,
233
234
  help_text="""The address street number""",
234
235
  )
235
236
  address_line1 = serializers.CharField(
236
237
  required=False,
237
238
  allow_blank=True,
238
239
  allow_null=True,
239
- max_length=50,
240
+ max_length=100,
240
241
  help_text="""The address line with street number <br/>
241
242
  **(required for shipment purchase)**
242
243
  """,
@@ -245,7 +246,7 @@ class AddressData(validators.AugmentedAddressSerializer):
245
246
  required=False,
246
247
  allow_blank=True,
247
248
  allow_null=True,
248
- max_length=50,
249
+ max_length=100,
249
250
  help_text="The address line with suite number",
250
251
  )
251
252
  validate_location = serializers.BooleanField(
@@ -915,7 +916,7 @@ class PickupUpdateRequest(serializers.Serializer):
915
916
  )
916
917
  ready_time = serializers.CharField(
917
918
  required=True,
918
- validators=[(validators.valid_time_format("ready_time"))],
919
+ validators=[validators.valid_time_format("ready_time")],
919
920
  help_text="""The ready time for pickup.
920
921
  Time Format: `HH:MM`
921
922
  """,
@@ -313,6 +313,7 @@ class Address:
313
313
  try:
314
314
  # Import the validator module dynamically
315
315
  import importlib
316
+
316
317
  module = importlib.import_module(f"karrio.validators.{validator_name}")
317
318
  if hasattr(module, "METADATA"):
318
319
  validator_class = module.METADATA.Validator
@@ -355,9 +356,11 @@ class Address:
355
356
  # For backwards compatibility, check if Google or Canada Post is configured
356
357
  if any(config.GOOGLE_CLOUD_API_KEY or ""):
357
358
  from karrio.validators.googlegeocoding import Validator as GoogleGeocode
359
+
358
360
  return GoogleGeocode
359
361
  elif any(config.CANADAPOST_ADDRESS_COMPLETE_API_KEY or ""):
360
362
  from karrio.validators.addresscomplete import Validator as AddressComplete
363
+
361
364
  return AddressComplete
362
365
 
363
366
  raise Exception("No address validation service provider configured")
@@ -380,17 +383,18 @@ class Address:
380
383
  refs = references.REFERENCES
381
384
  if len(refs.get("address_validators", {})) > 0:
382
385
  # Get the first available validator
383
- validator_name = next(iter(refs["address_validators"].keys()))
384
-
385
- # Try to get the validator class from the references
386
- try:
387
- # Import the validator module dynamically
388
- import importlib
389
- module = importlib.import_module(f"karrio.validators.{validator_name}")
390
- if hasattr(module, "METADATA"):
391
- return module.METADATA.Validator
392
- except (ImportError, AttributeError) as e:
393
- logger.warning(f"Could not import validator {validator_name}: {e}")
386
+ validator_name = next(iter(refs["address_validators"].keys()))
387
+
388
+ # Try to get the validator class from the references
389
+ try:
390
+ # Import the validator module dynamically
391
+ import importlib
392
+
393
+ module = importlib.import_module(f"karrio.validators.{validator_name}")
394
+ if hasattr(module, "METADATA"):
395
+ return module.METADATA.Validator
396
+ except (ImportError, AttributeError) as e:
397
+ logger.warning(f"Could not import validator {validator_name}: {e}")
394
398
 
395
399
  # Fall back to legacy validator
396
400
  return Address._get_legacy_validator()
@@ -0,0 +1,101 @@
1
+ from django.core.management.base import BaseCommand
2
+ from karrio.server.providers.models import RateSheet
3
+
4
+
5
+ class Command(BaseCommand):
6
+ help = 'Migrate existing rate sheets from legacy format to optimized zone reuse structure'
7
+
8
+ def add_arguments(self, parser):
9
+ parser.add_argument(
10
+ '--dry-run',
11
+ action='store_true',
12
+ help='Show what would be migrated without making changes',
13
+ )
14
+ parser.add_argument(
15
+ '--force',
16
+ action='store_true',
17
+ help='Force migration even if rate sheet already has optimized structure',
18
+ )
19
+
20
+ def handle(self, *args, **options):
21
+ dry_run = options['dry_run']
22
+ force = options['force']
23
+
24
+ rate_sheets = RateSheet.objects.all()
25
+
26
+ if not rate_sheets.exists():
27
+ self.stdout.write(self.style.WARNING('No rate sheets found.'))
28
+ return
29
+
30
+ migrated_count = 0
31
+ skipped_count = 0
32
+ error_count = 0
33
+
34
+ for rate_sheet in rate_sheets:
35
+ try:
36
+ # Check if already migrated
37
+ if not force and (rate_sheet.zones or rate_sheet.service_rates):
38
+ self.stdout.write(
39
+ self.style.WARNING(f'Skipping {rate_sheet.name} - already has optimized structure')
40
+ )
41
+ skipped_count += 1
42
+ continue
43
+
44
+ # Check if has services with zones to migrate
45
+ has_zones = any(
46
+ service.zones for service in rate_sheet.services.all()
47
+ )
48
+
49
+ if not has_zones:
50
+ self.stdout.write(
51
+ self.style.WARNING(f'Skipping {rate_sheet.name} - no zones to migrate')
52
+ )
53
+ skipped_count += 1
54
+ continue
55
+
56
+ if dry_run:
57
+ self.stdout.write(
58
+ self.style.SUCCESS(f'Would migrate: {rate_sheet.name}')
59
+ )
60
+ migrated_count += 1
61
+ else:
62
+ # Perform migration
63
+ old_zones_count = sum(
64
+ len(service.zones or []) for service in rate_sheet.services.all()
65
+ )
66
+
67
+ rate_sheet.migrate_from_legacy_format()
68
+
69
+ new_zones_count = len(rate_sheet.zones or [])
70
+ new_rates_count = len(rate_sheet.service_rates or [])
71
+
72
+ self.stdout.write(
73
+ self.style.SUCCESS(
74
+ f'Migrated {rate_sheet.name}: '
75
+ f'{old_zones_count} duplicated zones → '
76
+ f'{new_zones_count} shared zones + {new_rates_count} rates'
77
+ )
78
+ )
79
+ migrated_count += 1
80
+
81
+ except Exception as e:
82
+ self.stdout.write(
83
+ self.style.ERROR(f'Error migrating {rate_sheet.name}: {str(e)}')
84
+ )
85
+ error_count += 1
86
+
87
+ # Summary
88
+ if dry_run:
89
+ self.stdout.write(
90
+ self.style.SUCCESS(
91
+ f'\nDry run complete: {migrated_count} rate sheets would be migrated, '
92
+ f'{skipped_count} skipped, {error_count} errors'
93
+ )
94
+ )
95
+ else:
96
+ self.stdout.write(
97
+ self.style.SUCCESS(
98
+ f'\nMigration complete: {migrated_count} rate sheets migrated, '
99
+ f'{skipped_count} skipped, {error_count} errors'
100
+ )
101
+ )
@@ -0,0 +1,50 @@
1
+ # Generated migration to add unique identifiers to ServiceLevel zones
2
+ from django.db import migrations
3
+ import uuid
4
+
5
+
6
+ def add_zone_identifiers(apps, schema_editor):
7
+ """Add unique IDs to existing zones in ServiceLevel objects"""
8
+ ServiceLevel = apps.get_model('providers', 'ServiceLevel')
9
+
10
+ for service in ServiceLevel.objects.all():
11
+ if service.zones:
12
+ updated = False
13
+ for i, zone in enumerate(service.zones):
14
+ if 'id' not in zone:
15
+ # Generate unique zone ID
16
+ zone['id'] = f"zone_{uuid.uuid4().hex[:8]}"
17
+ updated = True
18
+
19
+ if updated:
20
+ service.save(update_fields=['zones'])
21
+
22
+
23
+ def reverse_zone_identifiers(apps, schema_editor):
24
+ """Remove zone IDs (for rollback)"""
25
+ ServiceLevel = apps.get_model('providers', 'ServiceLevel')
26
+
27
+ for service in ServiceLevel.objects.all():
28
+ if service.zones:
29
+ updated = False
30
+ for zone in service.zones:
31
+ if 'id' in zone:
32
+ del zone['id']
33
+ updated = True
34
+
35
+ if updated:
36
+ service.save(update_fields=['zones'])
37
+
38
+
39
+ class Migration(migrations.Migration):
40
+
41
+ dependencies = [
42
+ ('providers', '0081_remove_alliedexpresssettings_carrier_ptr_and_more'),
43
+ ]
44
+
45
+ operations = [
46
+ migrations.RunPython(
47
+ add_zone_identifiers,
48
+ reverse_zone_identifiers
49
+ ),
50
+ ]
@@ -0,0 +1,33 @@
1
+ # Generated migration to add optimized rate sheet structure
2
+
3
+ from django.db import migrations, models
4
+ import karrio.server.core.models as core
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ dependencies = [
9
+ ('providers', '0082_add_zone_identifiers'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='ratesheet',
15
+ name='zones',
16
+ field=models.JSONField(
17
+ blank=True,
18
+ null=True,
19
+ default=core.field_default([]),
20
+ help_text="Shared zone definitions: [{'id': 'zone_1', 'label': 'Zone 1', 'cities': [...], 'country_codes': [...]}]"
21
+ ),
22
+ ),
23
+ migrations.AddField(
24
+ model_name='ratesheet',
25
+ name='service_rates',
26
+ field=models.JSONField(
27
+ blank=True,
28
+ null=True,
29
+ default=core.field_default([]),
30
+ help_text="Service-zone rate mapping: [{'service_id': 'svc_1', 'zone_id': 'zone_1', 'rate': 10.50}]"
31
+ ),
32
+ ),
33
+ ]
@@ -60,3 +60,133 @@ class ServiceLevel(core.OwnedEntity):
60
60
  @property
61
61
  def object_type(self):
62
62
  return "service_level"
63
+
64
+ @property
65
+ def computed_zones(self):
66
+ """
67
+ Computed property that returns zones in legacy format for backward compatibility.
68
+ If the service belongs to a rate sheet with optimized structure, reconstruct from there.
69
+ Otherwise, fall back to the service's own zones field.
70
+ """
71
+ # Check if this service belongs to a rate sheet with optimized structure
72
+ rate_sheet = getattr(self, '_rate_sheet_cache', None)
73
+ if not rate_sheet:
74
+ # Try to find rate sheet this service belongs to
75
+ try:
76
+ rate_sheet = self.service_sheet.first()
77
+ self._rate_sheet_cache = rate_sheet
78
+ except:
79
+ rate_sheet = None
80
+
81
+ if rate_sheet and rate_sheet.zones and rate_sheet.service_rates:
82
+ # Use optimized structure
83
+ return rate_sheet.get_service_zones_legacy(self.id)
84
+ else:
85
+ # Fall back to legacy zones field
86
+ return self.zones or []
87
+
88
+ def update_zone_cell(self, zone_id: str, field: str, value):
89
+ """Update a single field in a zone by ID or index with validation"""
90
+ # Define allowed fields with their validators
91
+ allowed_fields = {
92
+ 'rate': float,
93
+ 'min_weight': float,
94
+ 'max_weight': float,
95
+ 'transit_days': int,
96
+ 'transit_time': float,
97
+ 'label': str,
98
+ 'radius': float,
99
+ 'latitude': float,
100
+ 'longitude': float,
101
+ }
102
+
103
+ if field not in allowed_fields:
104
+ raise ValueError(f"Field '{field}' is not allowed for zone updates")
105
+
106
+ # Validate and convert the value
107
+ try:
108
+ if value is not None and value != '':
109
+ value = allowed_fields[field](value)
110
+ except (ValueError, TypeError):
111
+ raise ValueError(f"Invalid value '{value}' for field '{field}' (expected {allowed_fields[field].__name__})")
112
+
113
+ zones = self.zones or []
114
+
115
+ # First try to find by zone ID
116
+ for zone in zones:
117
+ if zone.get('id') == zone_id:
118
+ zone[field] = value
119
+ self.save(update_fields=['zones'])
120
+ return zone
121
+
122
+ # Fallback: try to find by index for zones without IDs
123
+ try:
124
+ zone_index = int(zone_id)
125
+ if 0 <= zone_index < len(zones):
126
+ zones[zone_index][field] = value
127
+ self.save(update_fields=['zones'])
128
+ return zones[zone_index]
129
+ except (ValueError, IndexError):
130
+ pass
131
+
132
+ raise ValueError(f"Zone {zone_id} not found")
133
+
134
+ def batch_update_cells(self, updates: list):
135
+ """
136
+ Batch update multiple zone cells with validation
137
+ updates format: [{'zone_id': str, 'field': str, 'value': any}, ...]
138
+ """
139
+ # Define allowed fields with their validators
140
+ allowed_fields = {
141
+ 'rate': float,
142
+ 'min_weight': float,
143
+ 'max_weight': float,
144
+ 'transit_days': int,
145
+ 'transit_time': float,
146
+ 'label': str,
147
+ 'radius': float,
148
+ 'latitude': float,
149
+ 'longitude': float,
150
+ }
151
+
152
+ zones = list(self.zones or [])
153
+
154
+ for update in updates:
155
+ zone_id = update.get('zone_id')
156
+ field = update.get('field')
157
+ value = update.get('value')
158
+
159
+ if field not in allowed_fields:
160
+ raise ValueError(f"Field '{field}' is not allowed for zone updates")
161
+
162
+ # Validate and convert the value
163
+ try:
164
+ if value is not None and value != '':
165
+ value = allowed_fields[field](value)
166
+ except (ValueError, TypeError):
167
+ raise ValueError(f"Invalid value '{value}' for field '{field}' (expected {allowed_fields[field].__name__})")
168
+
169
+ # Find zone by ID first, then by index
170
+ zone_found = False
171
+ for zone in zones:
172
+ if zone.get('id') == zone_id:
173
+ zone[field] = value
174
+ zone_found = True
175
+ break
176
+
177
+ # Fallback to index if zone_id is numeric and zone not found by ID
178
+ if not zone_found:
179
+ try:
180
+ zone_index = int(zone_id)
181
+ if 0 <= zone_index < len(zones):
182
+ zones[zone_index][field] = value
183
+ zone_found = True
184
+ except (ValueError, IndexError):
185
+ pass
186
+
187
+ if not zone_found:
188
+ raise ValueError(f"Zone {zone_id} not found")
189
+
190
+ self.zones = zones
191
+ self.save(update_fields=['zones'])
192
+ return self.zones
@@ -29,6 +29,22 @@ class RateSheet(core.OwnedEntity):
29
29
  services = models.ManyToManyField(
30
30
  "ServiceLevel", blank=True, related_name="service_sheet"
31
31
  )
32
+
33
+ # New optimized structure
34
+ zones = models.JSONField(
35
+ blank=True,
36
+ null=True,
37
+ default=core.field_default([]),
38
+ help_text="Shared zone definitions: [{'id': 'zone_1', 'label': 'Zone 1', 'cities': [...], 'country_codes': [...]}]"
39
+ )
40
+ service_rates = models.JSONField(
41
+ blank=True,
42
+ null=True,
43
+ default=core.field_default([]),
44
+ help_text="Service-zone rate mapping: [{'service_id': 'svc_1', 'zone_id': 'zone_1', 'rate': 10.50}]"
45
+ )
46
+
47
+ # Keep old structure for backward compatibility during migration
32
48
  metadata = models.JSONField(
33
49
  blank=True,
34
50
  null=True,
@@ -58,3 +74,214 @@ class RateSheet(core.OwnedEntity):
58
74
  return providers.Carrier.objects.filter(
59
75
  carrier_code=self.carrier_name, rate_sheet__id=self.id
60
76
  )
77
+
78
+ def get_service_zones_legacy(self, service_id: str):
79
+ """
80
+ Backward compatible method - returns zones in old format for SDK compatibility
81
+ Combines shared zones with service-specific rates
82
+ """
83
+ zones = self.zones or []
84
+ service_rates = self.service_rates or []
85
+
86
+ # Get rates for this service
87
+ service_rate_map = {
88
+ sr['zone_id']: sr for sr in service_rates
89
+ if sr.get('service_id') == service_id
90
+ }
91
+
92
+ # Combine zone definitions with service rates
93
+ legacy_zones = []
94
+ for zone in zones:
95
+ zone_id = zone.get('id')
96
+ rate_data = service_rate_map.get(zone_id, {})
97
+
98
+ legacy_zone = {
99
+ **zone, # Zone definition (label, cities, country_codes, etc.)
100
+ 'rate': rate_data.get('rate', 0),
101
+ 'min_weight': rate_data.get('min_weight'),
102
+ 'max_weight': rate_data.get('max_weight'),
103
+ 'transit_days': rate_data.get('transit_days'),
104
+ 'transit_time': rate_data.get('transit_time'),
105
+ }
106
+ legacy_zones.append(legacy_zone)
107
+
108
+ return legacy_zones
109
+
110
+ def update_service_zone_rate(self, service_id: str, zone_id: str, field: str, value):
111
+ """
112
+ Update a rate field for a specific service-zone combination
113
+ """
114
+ allowed_fields = {
115
+ 'rate': float,
116
+ 'min_weight': float,
117
+ 'max_weight': float,
118
+ 'transit_days': int,
119
+ 'transit_time': float,
120
+ }
121
+
122
+ if field not in allowed_fields:
123
+ raise ValueError(f"Field '{field}' is not allowed for rate updates")
124
+
125
+ # Validate value
126
+ try:
127
+ if value is not None and value != '':
128
+ value = allowed_fields[field](value)
129
+ except (ValueError, TypeError):
130
+ raise ValueError(f"Invalid value '{value}' for field '{field}'")
131
+
132
+ service_rates = list(self.service_rates or [])
133
+
134
+ # Find existing rate record
135
+ for rate_record in service_rates:
136
+ if (rate_record.get('service_id') == service_id and
137
+ rate_record.get('zone_id') == zone_id):
138
+ rate_record[field] = value
139
+ break
140
+ else:
141
+ # Create new rate record
142
+ service_rates.append({
143
+ 'service_id': service_id,
144
+ 'zone_id': zone_id,
145
+ field: value
146
+ })
147
+
148
+ self.service_rates = service_rates
149
+ self.save(update_fields=['service_rates'])
150
+
151
+ def batch_update_service_rates(self, updates):
152
+ """
153
+ Batch update service rates
154
+ updates format: [{'service_id': str, 'zone_id': str, 'field': str, 'value': any}]
155
+ """
156
+ allowed_fields = {
157
+ 'rate': float,
158
+ 'min_weight': float,
159
+ 'max_weight': float,
160
+ 'transit_days': int,
161
+ 'transit_time': float,
162
+ }
163
+
164
+ service_rates = list(self.service_rates or [])
165
+ service_rate_map = {}
166
+
167
+ # Create lookup map for existing rates
168
+ for i, rate in enumerate(service_rates):
169
+ key = f"{rate.get('service_id')}:{rate.get('zone_id')}"
170
+ service_rate_map[key] = i
171
+
172
+ for update in updates:
173
+ service_id = update.get('service_id')
174
+ zone_id = update.get('zone_id')
175
+ field = update.get('field')
176
+ value = update.get('value')
177
+
178
+ if field not in allowed_fields:
179
+ continue
180
+
181
+ # Validate value
182
+ try:
183
+ if value is not None and value != '':
184
+ value = allowed_fields[field](value)
185
+ except (ValueError, TypeError):
186
+ continue
187
+
188
+ key = f"{service_id}:{zone_id}"
189
+
190
+ if key in service_rate_map:
191
+ # Update existing rate
192
+ service_rates[service_rate_map[key]][field] = value
193
+ else:
194
+ # Create new rate record
195
+ new_rate = {
196
+ 'service_id': service_id,
197
+ 'zone_id': zone_id,
198
+ field: value
199
+ }
200
+ service_rates.append(new_rate)
201
+ service_rate_map[key] = len(service_rates) - 1
202
+
203
+ self.service_rates = service_rates
204
+ self.save(update_fields=['service_rates'])
205
+
206
+ def add_zone(self, zone_data):
207
+ """
208
+ Add a new shared zone definition
209
+ """
210
+ zones = list(self.zones or [])
211
+
212
+ # Generate zone ID if not provided
213
+ if not zone_data.get('id'):
214
+ zone_data['id'] = f"zone_{len(zones) + 1}"
215
+
216
+ zones.append(zone_data)
217
+ self.zones = zones
218
+ self.save(update_fields=['zones'])
219
+ return zone_data['id']
220
+
221
+ def remove_zone(self, zone_id: str):
222
+ """
223
+ Remove a zone and all its associated rates
224
+ """
225
+ # Remove zone definition
226
+ zones = [z for z in (self.zones or []) if z.get('id') != zone_id]
227
+ self.zones = zones
228
+
229
+ # Remove all rates for this zone
230
+ service_rates = [sr for sr in (self.service_rates or []) if sr.get('zone_id') != zone_id]
231
+ self.service_rates = service_rates
232
+
233
+ self.save(update_fields=['zones', 'service_rates'])
234
+
235
+ def migrate_from_legacy_format(self):
236
+ """
237
+ Migrate from old format where zones are stored per service to new shared format
238
+ """
239
+ if self.zones or self.service_rates:
240
+ # Already in new format
241
+ return
242
+
243
+ all_zones = {}
244
+ service_rates = []
245
+ zone_counter = 1
246
+
247
+ # Extract unique zones across all services
248
+ for service in self.services.all():
249
+ service_zones = service.zones or []
250
+
251
+ for zone_index, zone_data in enumerate(service_zones):
252
+ # Create zone signature for deduplication
253
+ zone_signature = {
254
+ 'label': zone_data.get('label', f'Zone {zone_index + 1}'),
255
+ 'cities': sorted(zone_data.get('cities', [])),
256
+ 'postal_codes': sorted(zone_data.get('postal_codes', [])),
257
+ 'country_codes': sorted(zone_data.get('country_codes', [])),
258
+ }
259
+
260
+ # Use signature as key for deduplication
261
+ sig_key = str(zone_signature)
262
+
263
+ if sig_key not in all_zones:
264
+ zone_id = f"zone_{zone_counter}"
265
+ all_zones[sig_key] = {
266
+ 'id': zone_id,
267
+ **zone_signature
268
+ }
269
+ zone_counter += 1
270
+
271
+ zone_id = all_zones[sig_key]['id']
272
+
273
+ # Store service rate
274
+ service_rates.append({
275
+ 'service_id': service.id,
276
+ 'zone_id': zone_id,
277
+ 'rate': zone_data.get('rate', 0),
278
+ 'min_weight': zone_data.get('min_weight'),
279
+ 'max_weight': zone_data.get('max_weight'),
280
+ 'transit_days': zone_data.get('transit_days'),
281
+ 'transit_time': zone_data.get('transit_time'),
282
+ })
283
+
284
+ # Save optimized structure
285
+ self.zones = list(all_zones.values())
286
+ self.service_rates = service_rates
287
+ self.save(update_fields=['zones', 'service_rates'])
@@ -223,6 +223,7 @@ def link_org(entity: ModelSerializer, context: Context):
223
223
  update_fields=(["created_at"] if hasattr(entity, "created_at") else [])
224
224
  )
225
225
 
226
+
226
227
  def bulk_link_org(entities: typing.List[models.Model], context: Context):
227
228
  if len(entities) == 0 or settings.MULTI_ORGANIZATIONS is False:
228
229
  return
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karrio_server_core
3
- Version: 2025.5rc11
3
+ Version: 2025.5rc13
4
4
  Summary: Multi-carrier shipping API Core module
5
5
  Author-email: karrio <hello@karrio.io>
6
6
  License-Expression: Apache-2.0
@@ -17,12 +17,12 @@ karrio/server/core/oauth_validators.py,sha256=5JxDkXB_HX4a4xJltZcFGukO7o2cUl9h2t
17
17
  karrio/server/core/permissions.py,sha256=JY3_hTEnyDTWG-weAWPx8xMoaRZwpNL2HaSssBzjH7Y,1189
18
18
  karrio/server/core/renderers.py,sha256=IUEUhvvrk_CeqnmnYQgWKHiH3uQhNc0eqDxyO9CuUS4,290
19
19
  karrio/server/core/router.py,sha256=IBUR7rfBkdEHQzWxYOPcVSM8NBp3fte9G6Q5BVTUNNw,95
20
- karrio/server/core/serializers.py,sha256=5ubUTlytTvURGJ5B4O3Xf5tXFOVbHsjUEnzJhiyGqDM,60070
20
+ karrio/server/core/serializers.py,sha256=-wm4uQjtbR5z7qtAxJ7O2ApwzXmfFEdJkspQiVXeCj4,60074
21
21
  karrio/server/core/signals.py,sha256=thHh4K4uP3smWuy9kWavFfdOAttH9xN3og_j4lDWdKM,1742
22
22
  karrio/server/core/tests.py,sha256=rmZoZfPBRoMO-U3HtDxeKofnzsfijYeDVQrnw5Mb_dU,3164
23
23
  karrio/server/core/urls.py,sha256=JQrN6dI7A7c_M498EcDKdjDqlp_d64Ov-RS4HHmssRk,337
24
24
  karrio/server/core/utils.py,sha256=xY8aklHPtbaJsCPiKonk5HoY0FrSNseS859RZEQJ9mU,14022
25
- karrio/server/core/validators.py,sha256=Z0z_9MsPik5Vg0I-ZI43YCwglujT-GGouIeDA40kSr4,13970
25
+ karrio/server/core/validators.py,sha256=3XwjJurJJM6jKwNcZRUyr7j1k8DuOnhct1_1N9ildFg,13934
26
26
  karrio/server/core/management/commands/cli.py,sha256=EEIbFHZTroJoUxSOKnebsXtXTbsc408t5b9qSc0QRqk,720
27
27
  karrio/server/core/management/commands/create_oauth_client.py,sha256=wdxCeHuUtGpwgV0WItJkW8Di65y9krnFYNEmcgh9x2k,1718
28
28
  karrio/server/core/migrations/0001_initial.py,sha256=8c-JMkt84Z5C1Zgm2jw4NiI6tYSK4OLcB-uhxiEDN98,701
@@ -106,6 +106,7 @@ karrio/server/providers/extension/models/usps_international.py,sha256=k3opE3W4sv
106
106
  karrio/server/providers/extension/models/usps_wt.py,sha256=6RkR8mnFT9AWMEtW7l-ZB1BY9TkEYnLKtIiLyhwIqw0,731
107
107
  karrio/server/providers/extension/models/usps_wt_international.py,sha256=5QJdeNeshl1KCbQ6ravoh3vrU94QEN89noYZEF9kAW0,825
108
108
  karrio/server/providers/extension/models/zoom2u.py,sha256=3RvkZkAYvpXDW9fCmViOJB5NrSPo2_d_qpjcsWuCVtQ,585
109
+ karrio/server/providers/management/commands/migrate_rate_sheets.py,sha256=piRV1n0itHQR4HZiWXVSjJKzHZPnFGKlrCjWOcy723A,3814
109
110
  karrio/server/providers/migrations/0001_initial.py,sha256=DkKU91a1tMqRisLoarYndyKX3PJplT4Bor6tT53yETw,6772
110
111
  karrio/server/providers/migrations/0002_carrier_active.py,sha256=zW_6cFEKmsZIbiuLimcUlwzVvpckYroJmzkCwep-D2A,378
111
112
  karrio/server/providers/migrations/0003_auto_20201230_0820.py,sha256=qkvoSgtkGxg0XX0Srd35hanzl5tI7bBo3znli_h002g,699
@@ -187,12 +188,14 @@ karrio/server/providers/migrations/0078_auto_20240813_1552.py,sha256=1DXKf6Ak_GP
187
188
  karrio/server/providers/migrations/0079_alter_carrier_options_alter_ratesheet_created_by.py,sha256=wppkE5LZaPiedrLMAkUpVly5FlSg6zbLBzO7a_yyUuk,864
188
189
  karrio/server/providers/migrations/0080_alter_aramexsettings_account_country_code_and_more.py,sha256=ETTlvcAP1kk9Sr4u6f2EaG0hXcIaYVZKz0c6k7f-Cg8,101848
189
190
  karrio/server/providers/migrations/0081_remove_alliedexpresssettings_carrier_ptr_and_more.py,sha256=Lb8w5LEnJgCd-a4suFAdZ3-gWHlIbLIJLahTGlUmi_U,9557
191
+ karrio/server/providers/migrations/0082_add_zone_identifiers.py,sha256=0gRIFemNGNRkvV_pt0u3e-wL2LI89AKwaFWoqOqjAsA,1509
192
+ karrio/server/providers/migrations/0083_add_optimized_rate_sheet_structure.py,sha256=K9jj1LGEs5WQvIUxWac1wbCI6YnrrzTIcD3PzjZZcHA,1057
190
193
  karrio/server/providers/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
191
194
  karrio/server/providers/models/__init__.py,sha256=7D-pgQDTDSfptHhWxfJlOzSQqvYB_71D22zmNqjuBqk,628
192
195
  karrio/server/providers/models/carrier.py,sha256=E3zPcypTAQi1ninAQjKbelGAV8cZI8g_voRJ2KETrxQ,9436
193
196
  karrio/server/providers/models/config.py,sha256=8zuDXFGXBrC4fj2dW1-NpLAtFYPunEJi8uK-PezD7eo,759
194
- karrio/server/providers/models/service.py,sha256=_iT6Cty3HJJmjEXB423PjhEcQ4ArtMPKaxBZYTmhdaY,2161
195
- karrio/server/providers/models/sheet.py,sha256=GokxEfSAkjtKw5AkCtN0enP204RWRI0ZcQI5Lz-XgBU,1683
197
+ karrio/server/providers/models/service.py,sha256=XTkZKyzE2g0I7HxGluty6Nk4El7iSyj2Ze0YXnrF7WQ,7038
198
+ karrio/server/providers/models/sheet.py,sha256=Fuy8KIA719ZOeoqe-VfL6ZjLjhzdYS6Jp-hgE9680u4,9943
196
199
  karrio/server/providers/models/template.py,sha256=rrQXfimqFEFkelKzjyVeAruiu3UmrhpOT4M7JrGOtI0,1109
197
200
  karrio/server/providers/models/utils.py,sha256=fuvrpDz2KzcRC7w94zFQAaI4qtQPvs8f1r6jZ5xM-64,1703
198
201
  karrio/server/providers/serializers/__init__.py,sha256=bc5x1z7wkHyrHA2jP1wZVQF5SoPG-ueRVfn1RndK8VM,140
@@ -201,7 +204,7 @@ karrio/server/providers/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5N
201
204
  karrio/server/providers/views/carriers.py,sha256=_uD_soDZDmMxzfu8mQqeLLcDd4ixnE7f8HjoUlybym8,8808
202
205
  karrio/server/providers/views/connections.py,sha256=3Y1rfNsZxkn4YBA03WRQiI-y-AC4EYaOGaMjLmCcz1g,6038
203
206
  karrio/server/serializers/__init__.py,sha256=TzD3Lt8Gf7VV5ibGITGg5-ki-M8CR0hzZFulcI2ASJM,90
204
- karrio/server/serializers/abstract.py,sha256=_6ziZcVIpNr3s35LN-PiAZmcjD6a22xzluhvhJTsV-A,15582
207
+ karrio/server/serializers/abstract.py,sha256=WS4ey0W_NIMGl-5a2rpaZPdU9bKJmfDSpyHNs81PsGo,15583
205
208
  karrio/server/tracing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
206
209
  karrio/server/tracing/admin.py,sha256=6BZCJiBhJVg3vt8cYx9HF_FgcjanMD42la6bBORlSL8,1904
207
210
  karrio/server/tracing/apps.py,sha256=6BfIomUwBV8k7yaypl6kYYAkLSG1aceUmuxvbhw04fI,247
@@ -235,7 +238,7 @@ karrio/server/user/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5N
235
238
  karrio/server/user/templates/registration/login.html,sha256=3_tj-0rKfwkCk-fp_GT8xFQhLqjGcJs3uZzOAaI40Sw,3690
236
239
  karrio/server/user/templates/registration/registration_confirm_email.html,sha256=zFDkNN_BHMQyrBv_mU8aoqXxYxG91TGuf6pKwRa5jxE,247
237
240
  karrio/server/user/templates/registration/registration_confirm_email.txt,sha256=I_zN_pJTRigfyiYbyQK0wFfrI5Zq1JG8lf0TyLA9fN0,94
238
- karrio_server_core-2025.5rc11.dist-info/METADATA,sha256=vgYoK-0D6VP6I2f74PIbJw5kQ79B6Gi79Yo4iLqFajg,797
239
- karrio_server_core-2025.5rc11.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
240
- karrio_server_core-2025.5rc11.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
241
- karrio_server_core-2025.5rc11.dist-info/RECORD,,
241
+ karrio_server_core-2025.5rc13.dist-info/METADATA,sha256=U9GaWNyppHXD7rf279KJ33gn2l_Xv-fybNL9Im13L44,797
242
+ karrio_server_core-2025.5rc13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
243
+ karrio_server_core-2025.5rc13.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
244
+ karrio_server_core-2025.5rc13.dist-info/RECORD,,