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.
- karrio/server/core/serializers.py +12 -11
- karrio/server/core/validators.py +15 -11
- karrio/server/providers/management/commands/migrate_rate_sheets.py +101 -0
- karrio/server/providers/migrations/0082_add_zone_identifiers.py +50 -0
- karrio/server/providers/migrations/0083_add_optimized_rate_sheet_structure.py +33 -0
- karrio/server/providers/models/service.py +130 -0
- karrio/server/providers/models/sheet.py +227 -0
- karrio/server/serializers/abstract.py +1 -0
- {karrio_server_core-2025.5rc11.dist-info → karrio_server_core-2025.5rc13.dist-info}/METADATA +1 -1
- {karrio_server_core-2025.5rc11.dist-info → karrio_server_core-2025.5rc13.dist-info}/RECORD +12 -9
- {karrio_server_core-2025.5rc11.dist-info → karrio_server_core-2025.5rc13.dist-info}/WHEEL +0 -0
- {karrio_server_core-2025.5rc11.dist-info → karrio_server_core-2025.5rc13.dist-info}/top_level.txt +0 -0
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=[
|
|
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
|
""",
|
karrio/server/core/validators.py
CHANGED
|
@@ -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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
@@ -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
|
|
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=
|
|
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=
|
|
195
|
-
karrio/server/providers/models/sheet.py,sha256=
|
|
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=
|
|
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.
|
|
239
|
-
karrio_server_core-2025.
|
|
240
|
-
karrio_server_core-2025.
|
|
241
|
-
karrio_server_core-2025.
|
|
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,,
|
|
File without changes
|
{karrio_server_core-2025.5rc11.dist-info → karrio_server_core-2025.5rc13.dist-info}/top_level.txt
RENAMED
|
File without changes
|