karrio-server-core 2025.5rc12__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 (112) hide show
  1. karrio/server/core/authentication.py +59 -25
  2. karrio/server/core/config.py +31 -0
  3. karrio/server/core/datatypes.py +30 -4
  4. karrio/server/core/dataunits.py +53 -22
  5. karrio/server/core/exceptions.py +287 -17
  6. karrio/server/core/filters.py +14 -0
  7. karrio/server/core/gateway.py +285 -10
  8. karrio/server/core/logging.py +403 -0
  9. karrio/server/core/management/commands/runserver.py +5 -0
  10. karrio/server/core/middleware.py +104 -2
  11. karrio/server/core/migrations/0006_add_api_log_requested_at_index.py +22 -0
  12. karrio/server/core/models/base.py +34 -1
  13. karrio/server/core/oauth_validators.py +2 -3
  14. karrio/server/core/permissions.py +1 -2
  15. karrio/server/core/serializers.py +183 -10
  16. karrio/server/core/signals.py +22 -28
  17. karrio/server/core/telemetry.py +573 -0
  18. karrio/server/core/tests/__init__.py +27 -0
  19. karrio/server/core/{tests.py → tests/base.py} +6 -7
  20. karrio/server/core/tests/test_exception_level.py +159 -0
  21. karrio/server/core/tests/test_resource_token.py +593 -0
  22. karrio/server/core/utils.py +688 -38
  23. karrio/server/core/validators.py +144 -222
  24. karrio/server/core/views/oauth.py +13 -12
  25. karrio/server/core/views/references.py +2 -2
  26. karrio/server/iam/apps.py +1 -4
  27. karrio/server/iam/migrations/0002_setup_carrier_permission_groups.py +103 -0
  28. karrio/server/iam/migrations/0003_remove_permission_groups.py +91 -0
  29. karrio/server/iam/permissions.py +7 -134
  30. karrio/server/iam/serializers.py +17 -2
  31. karrio/server/iam/signals.py +2 -4
  32. karrio/server/providers/admin.py +1 -1
  33. karrio/server/providers/management/commands/migrate_rate_sheets.py +101 -0
  34. karrio/server/providers/migrations/0082_add_zone_identifiers.py +50 -0
  35. karrio/server/providers/migrations/0083_add_optimized_rate_sheet_structure.py +33 -0
  36. karrio/server/providers/migrations/0084_alter_servicelevel_currency.py +168 -0
  37. karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
  38. karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
  39. karrio/server/providers/migrations/0087_alter_carrier_capabilities.py +38 -0
  40. karrio/server/providers/migrations/0088_servicelevel_surcharges.py +24 -0
  41. karrio/server/providers/migrations/0089_servicelevel_cost_max_volume.py +31 -0
  42. karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py +47 -0
  43. karrio/server/providers/migrations/0091_migrate_legacy_zones_surcharges.py +154 -0
  44. karrio/server/providers/models/__init__.py +1 -2
  45. karrio/server/providers/models/carrier.py +103 -18
  46. karrio/server/providers/models/service.py +188 -1
  47. karrio/server/providers/models/sheet.py +371 -0
  48. karrio/server/providers/serializers/base.py +263 -2
  49. karrio/server/providers/signals.py +2 -4
  50. karrio/server/providers/templates/providers/oauth_callback.html +105 -0
  51. karrio/server/providers/tests/__init__.py +5 -0
  52. karrio/server/providers/tests/test_connections.py +895 -0
  53. karrio/server/providers/views/carriers.py +1 -3
  54. karrio/server/providers/views/connections.py +322 -2
  55. karrio/server/samples.py +1 -1
  56. karrio/server/serializers/abstract.py +116 -21
  57. karrio/server/tracing/migrations/0007_tracingrecord_tracing_created_at_idx.py +19 -0
  58. karrio/server/tracing/models.py +2 -0
  59. karrio/server/tracing/utils.py +5 -8
  60. karrio/server/user/migrations/0007_user_metadata.py +25 -0
  61. karrio/server/user/models.py +38 -23
  62. karrio/server/user/serializers.py +1 -0
  63. karrio/server/user/templates/registration/registration_confirm_email.html +1 -1
  64. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
  65. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +67 -86
  66. karrio/server/providers/extension/__init__.py +0 -1
  67. karrio/server/providers/extension/models/__init__.py +0 -1
  68. karrio/server/providers/extension/models/allied_express.py +0 -22
  69. karrio/server/providers/extension/models/allied_express_local.py +0 -22
  70. karrio/server/providers/extension/models/amazon_shipping.py +0 -27
  71. karrio/server/providers/extension/models/aramex.py +0 -25
  72. karrio/server/providers/extension/models/asendia_us.py +0 -21
  73. karrio/server/providers/extension/models/australiapost.py +0 -20
  74. karrio/server/providers/extension/models/boxknight.py +0 -19
  75. karrio/server/providers/extension/models/bpost.py +0 -21
  76. karrio/server/providers/extension/models/canadapost.py +0 -21
  77. karrio/server/providers/extension/models/canpar.py +0 -19
  78. karrio/server/providers/extension/models/chronopost.py +0 -22
  79. karrio/server/providers/extension/models/colissimo.py +0 -22
  80. karrio/server/providers/extension/models/dhl_express.py +0 -23
  81. karrio/server/providers/extension/models/dhl_parcel_de.py +0 -25
  82. karrio/server/providers/extension/models/dhl_poland.py +0 -22
  83. karrio/server/providers/extension/models/dhl_universal.py +0 -19
  84. karrio/server/providers/extension/models/dicom.py +0 -20
  85. karrio/server/providers/extension/models/dpd.py +0 -37
  86. karrio/server/providers/extension/models/dpdhl.py +0 -26
  87. karrio/server/providers/extension/models/easypost.py +0 -20
  88. karrio/server/providers/extension/models/eshipper.py +0 -21
  89. karrio/server/providers/extension/models/fedex.py +0 -25
  90. karrio/server/providers/extension/models/fedex_ws.py +0 -24
  91. karrio/server/providers/extension/models/freightcom.py +0 -21
  92. karrio/server/providers/extension/models/generic.py +0 -35
  93. karrio/server/providers/extension/models/geodis.py +0 -22
  94. karrio/server/providers/extension/models/hay_post.py +0 -22
  95. karrio/server/providers/extension/models/laposte.py +0 -19
  96. karrio/server/providers/extension/models/locate2u.py +0 -22
  97. karrio/server/providers/extension/models/nationex.py +0 -22
  98. karrio/server/providers/extension/models/purolator.py +0 -21
  99. karrio/server/providers/extension/models/roadie.py +0 -18
  100. karrio/server/providers/extension/models/royalmail.py +0 -19
  101. karrio/server/providers/extension/models/sendle.py +0 -22
  102. karrio/server/providers/extension/models/tge.py +0 -63
  103. karrio/server/providers/extension/models/tnt.py +0 -23
  104. karrio/server/providers/extension/models/ups.py +0 -23
  105. karrio/server/providers/extension/models/usps.py +0 -23
  106. karrio/server/providers/extension/models/usps_international.py +0 -23
  107. karrio/server/providers/extension/models/usps_wt.py +0 -24
  108. karrio/server/providers/extension/models/usps_wt_international.py +0 -24
  109. karrio/server/providers/extension/models/zoom2u.py +0 -23
  110. karrio/server/providers/tests.py +0 -3
  111. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
  112. {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/top_level.txt +0 -0
@@ -8,6 +8,13 @@ import karrio.server.core.datatypes as datatypes
8
8
 
9
9
  @core.register_model
10
10
  class ServiceLevel(core.OwnedEntity):
11
+ """
12
+ Service level definition for rate sheet-based shipping.
13
+
14
+ Services reference shared zones and surcharges defined at the RateSheet level
15
+ via zone_ids and surcharge_ids. Rate values are stored in RateSheet.service_rates.
16
+ """
17
+
11
18
  class Meta:
12
19
  db_table = "service-level"
13
20
  verbose_name = "Service Level"
@@ -51,7 +58,41 @@ class ServiceLevel(core.OwnedEntity):
51
58
  domicile = models.BooleanField(null=True)
52
59
  international = models.BooleanField(null=True)
53
60
 
54
- zones = models.JSONField(blank=True, null=True, default=core.field_default([]))
61
+ # ─────────────────────────────────────────────────────────────────
62
+ # VOLUMETRIC WEIGHT
63
+ # ─────────────────────────────────────────────────────────────────
64
+ max_volume = models.FloatField(
65
+ blank=True,
66
+ null=True,
67
+ help_text="Maximum volume in liters for volumetric weight calculation",
68
+ )
69
+
70
+ # ─────────────────────────────────────────────────────────────────
71
+ # COST TRACKING (internal - not shown to customer)
72
+ # ─────────────────────────────────────────────────────────────────
73
+ cost = models.FloatField(
74
+ blank=True,
75
+ null=True,
76
+ help_text="Base COGS (Cost of Goods Sold) - internal cost tracking",
77
+ )
78
+
79
+ # ─────────────────────────────────────────────────────────────────
80
+ # ZONE & SURCHARGE REFERENCES
81
+ # These reference shared definitions at the RateSheet level
82
+ # ─────────────────────────────────────────────────────────────────
83
+ zone_ids = models.JSONField(
84
+ blank=True,
85
+ null=True,
86
+ default=core.field_default([]),
87
+ help_text="List of zone IDs this service applies to: ['zone_1', 'zone_2']",
88
+ )
89
+ surcharge_ids = models.JSONField(
90
+ blank=True,
91
+ null=True,
92
+ default=core.field_default([]),
93
+ help_text="List of surcharge IDs to apply: ['surch_fuel', 'surch_residential']",
94
+ )
95
+
55
96
  metadata = models.JSONField(blank=True, null=True, default=core.field_default({}))
56
97
 
57
98
  def __str__(self):
@@ -60,3 +101,149 @@ class ServiceLevel(core.OwnedEntity):
60
101
  @property
61
102
  def object_type(self):
62
103
  return "service_level"
104
+
105
+ @property
106
+ def rate_sheet(self):
107
+ """Get the rate sheet this service belongs to."""
108
+ return self.service_sheet.first()
109
+
110
+ @property
111
+ def zones(self):
112
+ """
113
+ Get zones as ServiceZone objects for SDK compatibility.
114
+
115
+ Transforms zone_ids + rate_sheet data into the ServiceZone format
116
+ expected by the RatingMixinProxy. Each service_rate entry becomes
117
+ a separate ServiceZone (supporting multiple weight brackets per zone).
118
+ """
119
+ _rate_sheet = self.rate_sheet
120
+ if not _rate_sheet:
121
+ return []
122
+
123
+ zones_by_id = {z.get("id"): z for z in (_rate_sheet.zones or [])}
124
+
125
+ # Get all service_rates for this service (may have multiple per zone_id for weight brackets)
126
+ service_rates = [
127
+ sr for sr in (_rate_sheet.service_rates or [])
128
+ if sr.get("service_id") == self.id
129
+ and sr.get("zone_id") in (self.zone_ids or [])
130
+ ]
131
+
132
+ result = []
133
+ for rate_data in service_rates:
134
+ zone_id = rate_data.get("zone_id")
135
+ zone_def = zones_by_id.get(zone_id)
136
+
137
+ if not zone_def:
138
+ continue
139
+
140
+ # Build ServiceZone-compatible dict (one per service_rate entry)
141
+ result.append({
142
+ "id": zone_id,
143
+ "label": zone_def.get("label"),
144
+ "rate": rate_data.get("rate"),
145
+ "cost": rate_data.get("cost"),
146
+ "min_weight": rate_data.get("min_weight"),
147
+ "max_weight": rate_data.get("max_weight"),
148
+ "transit_days": rate_data.get("transit_days") or zone_def.get("transit_days"),
149
+ "transit_time": rate_data.get("transit_time") or zone_def.get("transit_time"),
150
+ "country_codes": zone_def.get("country_codes") or [],
151
+ "postal_codes": zone_def.get("postal_codes") or [],
152
+ "cities": zone_def.get("cities") or [],
153
+ "radius": zone_def.get("radius"),
154
+ "latitude": zone_def.get("latitude"),
155
+ "longitude": zone_def.get("longitude"),
156
+ })
157
+
158
+ return result
159
+
160
+ @property
161
+ def surcharges(self):
162
+ """
163
+ Get surcharges as Surcharge objects for SDK compatibility.
164
+
165
+ Transforms surcharge_ids + rate_sheet data into the Surcharge format
166
+ expected by the RatingMixinProxy.
167
+ """
168
+ _rate_sheet = self.rate_sheet
169
+ if not _rate_sheet:
170
+ return []
171
+
172
+ surcharges_by_id = {s.get("id"): s for s in (_rate_sheet.surcharges or [])}
173
+
174
+ result = []
175
+ for surcharge_id in (self.surcharge_ids or []):
176
+ surcharge_def = surcharges_by_id.get(surcharge_id)
177
+ if not surcharge_def or not surcharge_def.get("active", True):
178
+ continue
179
+
180
+ result.append({
181
+ "id": surcharge_id,
182
+ "name": surcharge_def.get("name"),
183
+ "amount": surcharge_def.get("amount"),
184
+ "surcharge_type": surcharge_def.get("surcharge_type", "fixed"),
185
+ "cost": surcharge_def.get("cost"),
186
+ "active": surcharge_def.get("active", True),
187
+ })
188
+
189
+ return result
190
+
191
+ def get_rate_for_zone(self, zone_id: str) -> dict:
192
+ """
193
+ Get the rate for a specific zone from the parent rate sheet.
194
+
195
+ Returns:
196
+ dict: Rate data including rate, cost, min_weight, max_weight, transit_days
197
+ """
198
+ rate_sheet = self.rate_sheet
199
+ if not rate_sheet:
200
+ return None
201
+ return rate_sheet.get_service_rate(self.id, zone_id)
202
+
203
+ def get_applicable_surcharges(self) -> list:
204
+ """
205
+ Get the applicable surcharges from the parent rate sheet.
206
+
207
+ Returns:
208
+ list: List of surcharge definitions
209
+ """
210
+ rate_sheet = self.rate_sheet
211
+ if not rate_sheet:
212
+ return []
213
+
214
+ surcharges = []
215
+ for surcharge_id in (self.surcharge_ids or []):
216
+ surcharge = rate_sheet.get_surcharge(surcharge_id)
217
+ if surcharge and surcharge.get('active', True):
218
+ surcharges.append(surcharge)
219
+ return surcharges
220
+
221
+ def calculate_rate(self, zone_id: str) -> tuple:
222
+ """
223
+ Calculate the total rate for a zone including surcharges.
224
+
225
+ Args:
226
+ zone_id: The zone ID to calculate rate for
227
+
228
+ Returns:
229
+ tuple: (total_rate, breakdown) where breakdown includes base rate and surcharges
230
+ """
231
+ rate_sheet = self.rate_sheet
232
+ if not rate_sheet:
233
+ return 0, []
234
+
235
+ # Get base rate for zone
236
+ rate_data = rate_sheet.get_service_rate(self.id, zone_id)
237
+ base_rate = float(rate_data.get('rate', 0)) if rate_data else 0
238
+
239
+ # Apply surcharges
240
+ total_rate, surcharge_breakdown = rate_sheet.apply_surcharges_to_rate(
241
+ base_rate, self.surcharge_ids or []
242
+ )
243
+
244
+ return total_rate, {
245
+ 'base_rate': base_rate,
246
+ 'base_cost': rate_data.get('cost') if rate_data else None,
247
+ 'surcharges': surcharge_breakdown,
248
+ 'total': total_rate,
249
+ }
@@ -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,6 +39,40 @@ class RateSheet(core.OwnedEntity):
29
39
  services = models.ManyToManyField(
30
40
  "ServiceLevel", blank=True, related_name="service_sheet"
31
41
  )
42
+
43
+ # ─────────────────────────────────────────────────────────────────
44
+ # SHARED ZONE DEFINITIONS
45
+ # Structure: [{'id': 'zone_1', 'label': 'Zone 1', 'country_codes': [...], 'cities': [...], 'postal_codes': [...], 'transit_days': int}]
46
+ # ─────────────────────────────────────────────────────────────────
47
+ zones = models.JSONField(
48
+ blank=True,
49
+ null=True,
50
+ default=core.field_default([]),
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'}]",
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
+ # ─────────────────────────────────────────────────────────────────
69
+ service_rates = models.JSONField(
70
+ blank=True,
71
+ null=True,
72
+ default=core.field_default([]),
73
+ help_text="Service-zone rate mapping: [{'service_id': 'svc_1', 'zone_id': 'zone_1', 'rate': 10.50}]",
74
+ )
75
+
32
76
  metadata = models.JSONField(
33
77
  blank=True,
34
78
  null=True,
@@ -58,3 +102,330 @@ class RateSheet(core.OwnedEntity):
58
102
  return providers.Carrier.objects.filter(
59
103
  carrier_code=self.carrier_name, rate_sheet__id=self.id
60
104
  )
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."""
247
+ service_rates = list(self.service_rates or [])
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)
259
+ self.service_rates = service_rates
260
+ self.save(update_fields=["service_rates"])
261
+ return new_rate
262
+
263
+ def batch_update_service_rates(self, updates: list):
264
+ """
265
+ Batch update service rates.
266
+ updates format: [{'service_id': str, 'zone_id': str, 'rate': float, 'cost': float, ...}, ...]
267
+ """
268
+ service_rates = list(self.service_rates or [])
269
+ rate_map = {}
270
+
271
+ for i, rate in enumerate(service_rates):
272
+ key = f"{rate.get('service_id')}:{rate.get('zone_id')}"
273
+ rate_map[key] = i
274
+
275
+ for update in updates:
276
+ service_id = update.get("service_id")
277
+ zone_id = update.get("zone_id")
278
+ if not service_id or not zone_id:
279
+ continue
280
+
281
+ key = f"{service_id}:{zone_id}"
282
+
283
+ if key in rate_map:
284
+ service_rates[rate_map[key]] = {**service_rates[rate_map[key]], **update}
285
+ else:
286
+ service_rates.append(update)
287
+ rate_map[key] = len(service_rates) - 1
288
+
289
+ self.service_rates = service_rates
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:
307
+ """
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)
316
+ """
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:
345
+ """
346
+ Calculate the total rate for a service-zone combination including surcharges.
347
+
348
+ Returns:
349
+ tuple: (total_rate, breakdown)
350
+ """
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:
368
+ """
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
373
+ """
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),
430
+ })
431
+ return result