karrio-server-core 2025.5rc31__py3-none-any.whl → 2026.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. karrio/server/core/authentication.py +38 -20
  2. karrio/server/core/config.py +31 -0
  3. karrio/server/core/datatypes.py +30 -4
  4. karrio/server/core/dataunits.py +26 -7
  5. karrio/server/core/exceptions.py +287 -17
  6. karrio/server/core/filters.py +14 -0
  7. karrio/server/core/gateway.py +284 -11
  8. karrio/server/core/logging.py +403 -0
  9. karrio/server/core/middleware.py +104 -2
  10. karrio/server/core/models/base.py +34 -1
  11. karrio/server/core/oauth_validators.py +2 -3
  12. karrio/server/core/permissions.py +1 -2
  13. karrio/server/core/serializers.py +154 -7
  14. karrio/server/core/signals.py +22 -28
  15. karrio/server/core/telemetry.py +573 -0
  16. karrio/server/core/tests/__init__.py +27 -0
  17. karrio/server/core/{tests.py → tests/base.py} +6 -7
  18. karrio/server/core/tests/test_exception_level.py +159 -0
  19. karrio/server/core/tests/test_resource_token.py +593 -0
  20. karrio/server/core/utils.py +688 -38
  21. karrio/server/core/validators.py +144 -222
  22. karrio/server/core/views/oauth.py +13 -12
  23. karrio/server/core/views/references.py +2 -2
  24. karrio/server/iam/apps.py +1 -4
  25. karrio/server/iam/migrations/0002_setup_carrier_permission_groups.py +103 -0
  26. karrio/server/iam/migrations/0003_remove_permission_groups.py +91 -0
  27. karrio/server/iam/permissions.py +7 -134
  28. karrio/server/iam/serializers.py +9 -3
  29. karrio/server/iam/signals.py +2 -4
  30. karrio/server/providers/admin.py +1 -1
  31. karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
  32. karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
  33. karrio/server/providers/migrations/0087_alter_carrier_capabilities.py +38 -0
  34. karrio/server/providers/migrations/0088_servicelevel_surcharges.py +24 -0
  35. karrio/server/providers/migrations/0089_servicelevel_cost_max_volume.py +31 -0
  36. karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py +47 -0
  37. karrio/server/providers/migrations/0091_migrate_legacy_zones_surcharges.py +154 -0
  38. karrio/server/providers/models/carrier.py +101 -29
  39. karrio/server/providers/models/service.py +182 -125
  40. karrio/server/providers/models/sheet.py +342 -198
  41. karrio/server/providers/serializers/base.py +263 -2
  42. karrio/server/providers/signals.py +2 -4
  43. karrio/server/providers/templates/providers/oauth_callback.html +105 -0
  44. karrio/server/providers/tests/__init__.py +5 -0
  45. karrio/server/providers/tests/test_connections.py +895 -0
  46. karrio/server/providers/views/carriers.py +1 -3
  47. karrio/server/providers/views/connections.py +322 -2
  48. karrio/server/serializers/abstract.py +112 -21
  49. karrio/server/tracing/utils.py +5 -8
  50. karrio/server/user/models.py +36 -34
  51. karrio/server/user/serializers.py +1 -0
  52. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
  53. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +55 -38
  54. karrio/server/providers/tests.py +0 -3
  55. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
  56. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/top_level.txt +0 -0
@@ -10,6 +10,16 @@ _ = translation.gettext_lazy
10
10
 
11
11
  @core.register_model
12
12
  class RateSheet(core.OwnedEntity):
13
+ """
14
+ Rate sheet with shared zones, surcharges, and service-zone rate mappings.
15
+
16
+ Structure:
17
+ - zones: Shared geographic zone definitions
18
+ - surcharges: Shared surcharge definitions (fuel, handling, etc.)
19
+ - service_rates: Service-zone rate mappings with weights and transit times
20
+ - services: Service level definitions that reference zones/surcharges by ID
21
+ """
22
+
13
23
  class Meta:
14
24
  db_table = "rate-sheet"
15
25
  verbose_name = "Rate Sheet"
@@ -29,22 +39,40 @@ class RateSheet(core.OwnedEntity):
29
39
  services = models.ManyToManyField(
30
40
  "ServiceLevel", blank=True, related_name="service_sheet"
31
41
  )
32
-
33
- # New optimized structure
42
+
43
+ # ─────────────────────────────────────────────────────────────────
44
+ # SHARED ZONE DEFINITIONS
45
+ # Structure: [{'id': 'zone_1', 'label': 'Zone 1', 'country_codes': [...], 'cities': [...], 'postal_codes': [...], 'transit_days': int}]
46
+ # ─────────────────────────────────────────────────────────────────
34
47
  zones = models.JSONField(
35
48
  blank=True,
36
49
  null=True,
37
50
  default=core.field_default([]),
38
- help_text="Shared zone definitions: [{'id': 'zone_1', 'label': 'Zone 1', 'cities': [...], 'country_codes': [...]}]"
51
+ help_text="Shared zone definitions: [{'id': 'zone_1', 'label': 'Zone 1', 'cities': [...], 'country_codes': [...]}]",
52
+ )
53
+
54
+ # ─────────────────────────────────────────────────────────────────
55
+ # SHARED SURCHARGE DEFINITIONS
56
+ # Structure: [{'id': 'surch_1', 'name': 'Fuel', 'amount': 8.5, 'surcharge_type': 'percentage', 'cost': 5.2, 'active': true}]
57
+ # ─────────────────────────────────────────────────────────────────
58
+ surcharges = models.JSONField(
59
+ blank=True,
60
+ null=True,
61
+ default=core.field_default([]),
62
+ help_text="Shared surcharge definitions: [{'id': 'surch_1', 'name': 'Fuel', 'amount': 8.5, 'surcharge_type': 'percentage'}]",
39
63
  )
64
+
65
+ # ─────────────────────────────────────────────────────────────────
66
+ # SERVICE-ZONE RATE MAPPING
67
+ # Structure: [{'service_id': 'svc_1', 'zone_id': 'zone_1', 'rate': 10.50, 'cost': 8.00, 'min_weight': 0, 'max_weight': 5}]
68
+ # ─────────────────────────────────────────────────────────────────
40
69
  service_rates = models.JSONField(
41
70
  blank=True,
42
71
  null=True,
43
72
  default=core.field_default([]),
44
- help_text="Service-zone rate mapping: [{'service_id': 'svc_1', 'zone_id': 'zone_1', 'rate': 10.50}]"
73
+ help_text="Service-zone rate mapping: [{'service_id': 'svc_1', 'zone_id': 'zone_1', 'rate': 10.50}]",
45
74
  )
46
-
47
- # Keep old structure for backward compatibility during migration
75
+
48
76
  metadata = models.JSONField(
49
77
  blank=True,
50
78
  null=True,
@@ -74,214 +102,330 @@ class RateSheet(core.OwnedEntity):
74
102
  return providers.Carrier.objects.filter(
75
103
  carrier_code=self.carrier_name, rate_sheet__id=self.id
76
104
  )
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
-
105
+
106
+ # ─────────────────────────────────────────────────────────────────
107
+ # ZONE MANAGEMENT
108
+ # ─────────────────────────────────────────────────────────────────
109
+
110
+ def add_zone(self, zone_data: dict) -> str:
111
+ """Add a new shared zone definition."""
112
+ zones = list(self.zones or [])
113
+
114
+ # Generate zone ID if not provided
115
+ if not zone_data.get("id"):
116
+ zone_data["id"] = f"zone_{len(zones) + 1}"
117
+
118
+ zones.append(zone_data)
119
+ self.zones = zones
120
+ self.save(update_fields=["zones"])
121
+ return zone_data["id"]
122
+
123
+ def update_zone(self, zone_id: str, zone_data: dict) -> dict:
124
+ """Update a shared zone definition."""
125
+ zones = list(self.zones or [])
126
+
127
+ for i, zone in enumerate(zones):
128
+ if zone.get("id") == zone_id:
129
+ zones[i] = {"id": zone_id, **{k: v for k, v in zone_data.items() if k != "id"}}
130
+ self.zones = zones
131
+ self.save(update_fields=["zones"])
132
+ return zones[i]
133
+
134
+ raise ValueError(f"Zone {zone_id} not found")
135
+
136
+ def remove_zone(self, zone_id: str):
137
+ """Remove a zone and all its associated rates."""
138
+ zones = [z for z in (self.zones or []) if z.get("id") != zone_id]
139
+ self.zones = zones
140
+
141
+ # Remove all rates for this zone
142
+ service_rates = [sr for sr in (self.service_rates or []) if sr.get("zone_id") != zone_id]
143
+ self.service_rates = service_rates
144
+
145
+ # Remove zone_id from services
146
+ for service in self.services.all():
147
+ if zone_id in (service.zone_ids or []):
148
+ service.zone_ids = [zid for zid in service.zone_ids if zid != zone_id]
149
+ service.save(update_fields=["zone_ids"])
150
+
151
+ self.save(update_fields=["zones", "service_rates"])
152
+
153
+ def get_zone(self, zone_id: str) -> dict:
154
+ """Get a zone by ID."""
155
+ for zone in self.zones or []:
156
+ if zone.get("id") == zone_id:
157
+ return zone
158
+ return None
159
+
160
+ # ─────────────────────────────────────────────────────────────────
161
+ # SURCHARGE MANAGEMENT
162
+ # ─────────────────────────────────────────────────────────────────
163
+
164
+ def add_surcharge(self, surcharge_data: dict) -> str:
165
+ """Add a new shared surcharge definition."""
166
+ surcharges = list(self.surcharges or [])
167
+
168
+ if not surcharge_data.get("id"):
169
+ surcharge_data["id"] = f"surch_{len(surcharges) + 1}"
170
+
171
+ surcharge_data.setdefault("active", True)
172
+ surcharge_data.setdefault("surcharge_type", "fixed")
173
+
174
+ surcharges.append(surcharge_data)
175
+ self.surcharges = surcharges
176
+ self.save(update_fields=["surcharges"])
177
+ return surcharge_data["id"]
178
+
179
+ def update_surcharge(self, surcharge_id: str, surcharge_data: dict) -> dict:
180
+ """Update a shared surcharge definition."""
181
+ surcharges = list(self.surcharges or [])
182
+
183
+ for i, surcharge in enumerate(surcharges):
184
+ if surcharge.get("id") == surcharge_id:
185
+ surcharges[i] = {"id": surcharge_id, **{k: v for k, v in surcharge_data.items() if k != "id"}}
186
+ self.surcharges = surcharges
187
+ self.save(update_fields=["surcharges"])
188
+ return surcharges[i]
189
+
190
+ raise ValueError(f"Surcharge {surcharge_id} not found")
191
+
192
+ def remove_surcharge(self, surcharge_id: str):
193
+ """Remove a surcharge definition and its references from services."""
194
+ surcharges = [s for s in (self.surcharges or []) if s.get("id") != surcharge_id]
195
+ self.surcharges = surcharges
196
+
197
+ # Remove surcharge_id from services
198
+ for service in self.services.all():
199
+ if surcharge_id in (service.surcharge_ids or []):
200
+ service.surcharge_ids = [sid for sid in service.surcharge_ids if sid != surcharge_id]
201
+ service.save(update_fields=["surcharge_ids"])
202
+
203
+ self.save(update_fields=["surcharges"])
204
+
205
+ def batch_update_surcharges(self, updates: list):
206
+ """Batch update multiple surcharges."""
207
+ surcharges = list(self.surcharges or [])
208
+ surcharge_map = {s.get("id"): i for i, s in enumerate(surcharges)}
209
+
210
+ for update in updates:
211
+ surcharge_id = update.get("id")
212
+ if not surcharge_id:
213
+ continue
214
+
215
+ if surcharge_id in surcharge_map:
216
+ idx = surcharge_map[surcharge_id]
217
+ surcharges[idx] = {**surcharges[idx], **update}
218
+ else:
219
+ update.setdefault("active", True)
220
+ update.setdefault("surcharge_type", "fixed")
221
+ surcharges.append(update)
222
+ surcharge_map[surcharge_id] = len(surcharges) - 1
223
+
224
+ self.surcharges = surcharges
225
+ self.save(update_fields=["surcharges"])
226
+
227
+ def get_surcharge(self, surcharge_id: str) -> dict:
228
+ """Get a surcharge by ID."""
229
+ for surcharge in self.surcharges or []:
230
+ if surcharge.get("id") == surcharge_id:
231
+ return surcharge
232
+ return None
233
+
234
+ # ─────────────────────────────────────────────────────────────────
235
+ # SERVICE RATE MANAGEMENT
236
+ # ─────────────────────────────────────────────────────────────────
237
+
238
+ def get_service_rate(self, service_id: str, zone_id: str) -> dict:
239
+ """Get a service rate by service_id and zone_id."""
240
+ for rate in self.service_rates or []:
241
+ if rate.get("service_id") == service_id and rate.get("zone_id") == zone_id:
242
+ return rate
243
+ return None
244
+
245
+ def update_service_rate(self, service_id: str, zone_id: str, rate_data: dict) -> dict:
246
+ """Update or create a service-zone rate mapping."""
132
247
  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
-
248
+
249
+ for i, rate in enumerate(service_rates):
250
+ if rate.get("service_id") == service_id and rate.get("zone_id") == zone_id:
251
+ service_rates[i] = {"service_id": service_id, "zone_id": zone_id, **rate_data}
252
+ self.service_rates = service_rates
253
+ self.save(update_fields=["service_rates"])
254
+ return service_rates[i]
255
+
256
+ # Create new rate record
257
+ new_rate = {"service_id": service_id, "zone_id": zone_id, **rate_data}
258
+ service_rates.append(new_rate)
148
259
  self.service_rates = service_rates
149
- self.save(update_fields=['service_rates'])
150
-
151
- def batch_update_service_rates(self, updates):
260
+ self.save(update_fields=["service_rates"])
261
+ return new_rate
262
+
263
+ def batch_update_service_rates(self, updates: list):
152
264
  """
153
- Batch update service rates
154
- updates format: [{'service_id': str, 'zone_id': str, 'field': str, 'value': any}]
265
+ Batch update service rates.
266
+ updates format: [{'service_id': str, 'zone_id': str, 'rate': float, 'cost': float, ...}, ...]
155
267
  """
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
268
  service_rates = list(self.service_rates or [])
165
- service_rate_map = {}
166
-
167
- # Create lookup map for existing rates
269
+ rate_map = {}
270
+
168
271
  for i, rate in enumerate(service_rates):
169
272
  key = f"{rate.get('service_id')}:{rate.get('zone_id')}"
170
- service_rate_map[key] = i
171
-
273
+ rate_map[key] = i
274
+
172
275
  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):
276
+ service_id = update.get("service_id")
277
+ zone_id = update.get("zone_id")
278
+ if not service_id or not zone_id:
186
279
  continue
187
-
280
+
188
281
  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
282
+
283
+ if key in rate_map:
284
+ service_rates[rate_map[key]] = {**service_rates[rate_map[key]], **update}
193
285
  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
-
286
+ service_rates.append(update)
287
+ rate_map[key] = len(service_rates) - 1
288
+
203
289
  self.service_rates = service_rates
204
- self.save(update_fields=['service_rates'])
205
-
206
- def add_zone(self, zone_data):
290
+ self.save(update_fields=["service_rates"])
291
+
292
+ def remove_service_rate(self, service_id: str, zone_id: str):
293
+ """Remove a service-zone rate mapping."""
294
+ service_rates = [
295
+ sr
296
+ for sr in (self.service_rates or [])
297
+ if not (sr.get("service_id") == service_id and sr.get("zone_id") == zone_id)
298
+ ]
299
+ self.service_rates = service_rates
300
+ self.save(update_fields=["service_rates"])
301
+
302
+ # ─────────────────────────────────────────────────────────────────
303
+ # RATE CALCULATION
304
+ # ─────────────────────────────────────────────────────────────────
305
+
306
+ def apply_surcharges_to_rate(self, base_rate: float, surcharge_ids: list) -> tuple:
207
307
  """
208
- Add a new shared zone definition
308
+ Apply surcharges to a base rate.
309
+
310
+ Args:
311
+ base_rate: The base rate before surcharges
312
+ surcharge_ids: List of surcharge IDs to apply
313
+
314
+ Returns:
315
+ tuple: (final_rate, breakdown)
209
316
  """
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):
317
+ total_surcharge = 0.0
318
+ breakdown = []
319
+
320
+ for surcharge_id in surcharge_ids:
321
+ surcharge = self.get_surcharge(surcharge_id)
322
+ if not surcharge or not surcharge.get("active", True):
323
+ continue
324
+
325
+ name = surcharge.get("name", "Surcharge")
326
+ amount = float(surcharge.get("amount", 0))
327
+ surcharge_type = surcharge.get("surcharge_type", "fixed")
328
+
329
+ applied_amount = (base_rate * amount / 100) if surcharge_type == "percentage" else amount
330
+ total_surcharge += applied_amount
331
+ breakdown.append(
332
+ {
333
+ "id": surcharge_id,
334
+ "name": name,
335
+ "amount": applied_amount,
336
+ "surcharge_type": surcharge_type,
337
+ "original_value": amount,
338
+ "cost": surcharge.get("cost"),
339
+ }
340
+ )
341
+
342
+ return base_rate + total_surcharge, breakdown
343
+
344
+ def calculate_rate(self, service_id: str, zone_id: str) -> tuple:
222
345
  """
223
- Remove a zone and all its associated rates
346
+ Calculate the total rate for a service-zone combination including surcharges.
347
+
348
+ Returns:
349
+ tuple: (total_rate, breakdown)
224
350
  """
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):
351
+ rate_data = self.get_service_rate(service_id, zone_id)
352
+ base_rate = float(rate_data.get("rate", 0)) if rate_data else 0
353
+
354
+ # Get service surcharge_ids
355
+ service = self.services.filter(id=service_id).first()
356
+ surcharge_ids = service.surcharge_ids if service else []
357
+
358
+ total_rate, surcharge_breakdown = self.apply_surcharges_to_rate(base_rate, surcharge_ids or [])
359
+
360
+ return total_rate, {
361
+ "base_rate": base_rate,
362
+ "base_cost": rate_data.get("cost") if rate_data else None,
363
+ "surcharges": surcharge_breakdown,
364
+ "total": total_rate,
365
+ }
366
+
367
+ def get_service_zones_for_rating(self, service_id: str) -> list:
236
368
  """
237
- Migrate from old format where zones are stored per service to new shared format
369
+ Get zones with rates for a service, formatted for SDK rate calculation.
370
+
371
+ Returns:
372
+ list: Zone dicts with rate data merged from service_rates
238
373
  """
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'),
374
+ zones = self.zones or []
375
+ service_rates = self.service_rates or []
376
+
377
+ # Build rate lookup
378
+ rate_map = {
379
+ sr.get("zone_id"): sr
380
+ for sr in service_rates
381
+ if sr.get("service_id") == service_id
382
+ }
383
+
384
+ # Merge zone definitions with rates
385
+ result = []
386
+ for zone in zones:
387
+ zone_id = zone.get("id")
388
+ rate_data = rate_map.get(zone_id, {})
389
+
390
+ result.append({
391
+ "id": zone_id,
392
+ "label": zone.get("label"),
393
+ "rate": rate_data.get("rate", 0),
394
+ "cost": rate_data.get("cost"),
395
+ "min_weight": rate_data.get("min_weight"),
396
+ "max_weight": rate_data.get("max_weight"),
397
+ "transit_days": rate_data.get("transit_days") or zone.get("transit_days"),
398
+ "transit_time": rate_data.get("transit_time") or zone.get("transit_time"),
399
+ "radius": zone.get("radius"),
400
+ "latitude": zone.get("latitude"),
401
+ "longitude": zone.get("longitude"),
402
+ "cities": zone.get("cities", []),
403
+ "postal_codes": zone.get("postal_codes", []),
404
+ "country_codes": zone.get("country_codes", []),
405
+ })
406
+
407
+ return result
408
+
409
+ def get_surcharges_for_rating(self, surcharge_ids: list) -> list:
410
+ """
411
+ Get surcharges formatted for SDK rate calculation.
412
+
413
+ Args:
414
+ surcharge_ids: List of surcharge IDs to include
415
+
416
+ Returns:
417
+ list: Surcharge dicts for active surcharges
418
+ """
419
+ result = []
420
+ for surcharge_id in surcharge_ids or []:
421
+ surcharge = self.get_surcharge(surcharge_id)
422
+ if surcharge and surcharge.get("active", True):
423
+ result.append({
424
+ "id": surcharge.get("id"),
425
+ "name": surcharge.get("name"),
426
+ "amount": surcharge.get("amount", 0),
427
+ "surcharge_type": surcharge.get("surcharge_type", "fixed"),
428
+ "cost": surcharge.get("cost"),
429
+ "active": surcharge.get("active", True),
282
430
  })
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'])
431
+ return result