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.
- karrio/server/core/authentication.py +59 -25
- karrio/server/core/config.py +31 -0
- karrio/server/core/datatypes.py +30 -4
- karrio/server/core/dataunits.py +53 -22
- karrio/server/core/exceptions.py +287 -17
- karrio/server/core/filters.py +14 -0
- karrio/server/core/gateway.py +285 -10
- karrio/server/core/logging.py +403 -0
- karrio/server/core/management/commands/runserver.py +5 -0
- karrio/server/core/middleware.py +104 -2
- karrio/server/core/migrations/0006_add_api_log_requested_at_index.py +22 -0
- karrio/server/core/models/base.py +34 -1
- karrio/server/core/oauth_validators.py +2 -3
- karrio/server/core/permissions.py +1 -2
- karrio/server/core/serializers.py +183 -10
- karrio/server/core/signals.py +22 -28
- karrio/server/core/telemetry.py +573 -0
- karrio/server/core/tests/__init__.py +27 -0
- karrio/server/core/{tests.py → tests/base.py} +6 -7
- karrio/server/core/tests/test_exception_level.py +159 -0
- karrio/server/core/tests/test_resource_token.py +593 -0
- karrio/server/core/utils.py +688 -38
- karrio/server/core/validators.py +144 -222
- karrio/server/core/views/oauth.py +13 -12
- karrio/server/core/views/references.py +2 -2
- karrio/server/iam/apps.py +1 -4
- karrio/server/iam/migrations/0002_setup_carrier_permission_groups.py +103 -0
- karrio/server/iam/migrations/0003_remove_permission_groups.py +91 -0
- karrio/server/iam/permissions.py +7 -134
- karrio/server/iam/serializers.py +17 -2
- karrio/server/iam/signals.py +2 -4
- karrio/server/providers/admin.py +1 -1
- 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/migrations/0084_alter_servicelevel_currency.py +168 -0
- karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
- karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
- karrio/server/providers/migrations/0087_alter_carrier_capabilities.py +38 -0
- karrio/server/providers/migrations/0088_servicelevel_surcharges.py +24 -0
- karrio/server/providers/migrations/0089_servicelevel_cost_max_volume.py +31 -0
- karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py +47 -0
- karrio/server/providers/migrations/0091_migrate_legacy_zones_surcharges.py +154 -0
- karrio/server/providers/models/__init__.py +1 -2
- karrio/server/providers/models/carrier.py +103 -18
- karrio/server/providers/models/service.py +188 -1
- karrio/server/providers/models/sheet.py +371 -0
- karrio/server/providers/serializers/base.py +263 -2
- karrio/server/providers/signals.py +2 -4
- karrio/server/providers/templates/providers/oauth_callback.html +105 -0
- karrio/server/providers/tests/__init__.py +5 -0
- karrio/server/providers/tests/test_connections.py +895 -0
- karrio/server/providers/views/carriers.py +1 -3
- karrio/server/providers/views/connections.py +322 -2
- karrio/server/samples.py +1 -1
- karrio/server/serializers/abstract.py +116 -21
- karrio/server/tracing/migrations/0007_tracingrecord_tracing_created_at_idx.py +19 -0
- karrio/server/tracing/models.py +2 -0
- karrio/server/tracing/utils.py +5 -8
- karrio/server/user/migrations/0007_user_metadata.py +25 -0
- karrio/server/user/models.py +38 -23
- karrio/server/user/serializers.py +1 -0
- karrio/server/user/templates/registration/registration_confirm_email.html +1 -1
- {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
- {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +67 -86
- karrio/server/providers/extension/__init__.py +0 -1
- karrio/server/providers/extension/models/__init__.py +0 -1
- karrio/server/providers/extension/models/allied_express.py +0 -22
- karrio/server/providers/extension/models/allied_express_local.py +0 -22
- karrio/server/providers/extension/models/amazon_shipping.py +0 -27
- karrio/server/providers/extension/models/aramex.py +0 -25
- karrio/server/providers/extension/models/asendia_us.py +0 -21
- karrio/server/providers/extension/models/australiapost.py +0 -20
- karrio/server/providers/extension/models/boxknight.py +0 -19
- karrio/server/providers/extension/models/bpost.py +0 -21
- karrio/server/providers/extension/models/canadapost.py +0 -21
- karrio/server/providers/extension/models/canpar.py +0 -19
- karrio/server/providers/extension/models/chronopost.py +0 -22
- karrio/server/providers/extension/models/colissimo.py +0 -22
- karrio/server/providers/extension/models/dhl_express.py +0 -23
- karrio/server/providers/extension/models/dhl_parcel_de.py +0 -25
- karrio/server/providers/extension/models/dhl_poland.py +0 -22
- karrio/server/providers/extension/models/dhl_universal.py +0 -19
- karrio/server/providers/extension/models/dicom.py +0 -20
- karrio/server/providers/extension/models/dpd.py +0 -37
- karrio/server/providers/extension/models/dpdhl.py +0 -26
- karrio/server/providers/extension/models/easypost.py +0 -20
- karrio/server/providers/extension/models/eshipper.py +0 -21
- karrio/server/providers/extension/models/fedex.py +0 -25
- karrio/server/providers/extension/models/fedex_ws.py +0 -24
- karrio/server/providers/extension/models/freightcom.py +0 -21
- karrio/server/providers/extension/models/generic.py +0 -35
- karrio/server/providers/extension/models/geodis.py +0 -22
- karrio/server/providers/extension/models/hay_post.py +0 -22
- karrio/server/providers/extension/models/laposte.py +0 -19
- karrio/server/providers/extension/models/locate2u.py +0 -22
- karrio/server/providers/extension/models/nationex.py +0 -22
- karrio/server/providers/extension/models/purolator.py +0 -21
- karrio/server/providers/extension/models/roadie.py +0 -18
- karrio/server/providers/extension/models/royalmail.py +0 -19
- karrio/server/providers/extension/models/sendle.py +0 -22
- karrio/server/providers/extension/models/tge.py +0 -63
- karrio/server/providers/extension/models/tnt.py +0 -23
- karrio/server/providers/extension/models/ups.py +0 -23
- karrio/server/providers/extension/models/usps.py +0 -23
- karrio/server/providers/extension/models/usps_international.py +0 -23
- karrio/server/providers/extension/models/usps_wt.py +0 -24
- karrio/server/providers/extension/models/usps_wt_international.py +0 -24
- karrio/server/providers/extension/models/zoom2u.py +0 -23
- karrio/server/providers/tests.py +0 -3
- {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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
|