Autosave: 20260131-024701
This commit is contained in:
parent
d909e02470
commit
0a784beb41
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/pricing.cpython-311.pyc
Normal file
BIN
core/__pycache__/pricing.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from .models import Profile, Parcel, Country, Governate, City, PlatformProfile, Testimonial, DriverRating, NotificationTemplate
|
from .models import Profile, Parcel, Country, Governate, City, PlatformProfile, Testimonial, DriverRating, NotificationTemplate, PricingRule
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.urls import path, reverse
|
from django.urls import path, reverse
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
@ -94,7 +94,7 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
send_whatsapp_link.allow_tags = True
|
send_whatsapp_link.allow_tags = True
|
||||||
|
|
||||||
class ParcelAdmin(admin.ModelAdmin):
|
class ParcelAdmin(admin.ModelAdmin):
|
||||||
list_display = ('tracking_number', 'shipper', 'carrier', 'price', 'status', 'payment_status', 'created_at')
|
list_display = ('tracking_number', 'shipper', 'carrier', 'price', 'distance_km', 'status', 'payment_status', 'created_at')
|
||||||
list_filter = (
|
list_filter = (
|
||||||
'status',
|
'status',
|
||||||
'payment_status',
|
'payment_status',
|
||||||
@ -102,13 +102,32 @@ class ParcelAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
search_fields = ('tracking_number', 'shipper__username', 'receiver_name', 'carrier__username')
|
search_fields = ('tracking_number', 'shipper__username', 'receiver_name', 'carrier__username')
|
||||||
actions = ['export_as_csv', 'print_parcels', 'export_pdf']
|
actions = ['export_as_csv', 'print_parcels', 'export_pdf']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('tracking_number', 'shipper', 'carrier', 'status', 'payment_status', 'thawani_session_id')
|
||||||
|
}),
|
||||||
|
(_('Description'), {
|
||||||
|
'fields': ('description', 'receiver_name', 'receiver_phone')
|
||||||
|
}),
|
||||||
|
(_('Trip & Pricing'), {
|
||||||
|
'fields': ('distance_km', 'weight', 'price', 'platform_fee_percentage', 'platform_fee', 'driver_amount'),
|
||||||
|
'description': _('Pricing is calculated based on Distance and Weight.')
|
||||||
|
}),
|
||||||
|
(_('Pickup Location'), {
|
||||||
|
'fields': ('pickup_country', 'pickup_governate', 'pickup_city', 'pickup_address', 'pickup_lat', 'pickup_lng')
|
||||||
|
}),
|
||||||
|
(_('Delivery Location'), {
|
||||||
|
'fields': ('delivery_country', 'delivery_governate', 'delivery_city', 'delivery_address', 'delivery_lat', 'delivery_lng')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
def export_as_csv(self, request, queryset):
|
def export_as_csv(self, request, queryset):
|
||||||
response = HttpResponse(content_type='text/csv')
|
response = HttpResponse(content_type='text/csv')
|
||||||
response['Content-Disposition'] = 'attachment; filename="parcels_report.csv"'
|
response['Content-Disposition'] = 'attachment; filename="parcels_report.csv"'
|
||||||
writer = csv.writer(response)
|
writer = csv.writer(response)
|
||||||
|
|
||||||
writer.writerow(['Tracking Number', 'Shipper', 'Carrier', 'Price (OMR)', 'Status', 'Payment Status', 'Created At', 'Updated At'])
|
writer.writerow(['Tracking Number', 'Shipper', 'Carrier', 'Total Price (OMR)', 'Platform Fee (%)', 'Platform Charge (OMR)', 'Driver Amount (OMR)', 'Distance (km)', 'Weight (kg)', 'Status', 'Payment Status', 'Created At'])
|
||||||
|
|
||||||
for obj in queryset:
|
for obj in queryset:
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
@ -116,10 +135,14 @@ class ParcelAdmin(admin.ModelAdmin):
|
|||||||
obj.shipper.username if obj.shipper else '',
|
obj.shipper.username if obj.shipper else '',
|
||||||
obj.carrier.username if obj.carrier else '',
|
obj.carrier.username if obj.carrier else '',
|
||||||
obj.price,
|
obj.price,
|
||||||
|
obj.platform_fee_percentage,
|
||||||
|
obj.platform_fee,
|
||||||
|
obj.driver_amount,
|
||||||
|
obj.distance_km,
|
||||||
|
obj.weight,
|
||||||
obj.get_status_display(),
|
obj.get_status_display(),
|
||||||
obj.get_payment_status_display(),
|
obj.get_payment_status_display(),
|
||||||
obj.created_at,
|
obj.created_at
|
||||||
obj.updated_at
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@ -145,12 +168,16 @@ class PlatformProfileAdmin(admin.ModelAdmin):
|
|||||||
(_('General Info'), {
|
(_('General Info'), {
|
||||||
'fields': ('name', 'logo', 'slogan', 'address', 'phone_number', 'registration_number', 'vat_number')
|
'fields': ('name', 'logo', 'slogan', 'address', 'phone_number', 'registration_number', 'vat_number')
|
||||||
}),
|
}),
|
||||||
|
(_('Financial Configuration'), {
|
||||||
|
'fields': ('platform_fee_percentage', 'enable_payment')
|
||||||
|
}),
|
||||||
|
(_('Integrations'), {
|
||||||
|
'fields': ('google_maps_api_key',),
|
||||||
|
'description': _('API Keys for external services.')
|
||||||
|
}),
|
||||||
(_('Policies'), {
|
(_('Policies'), {
|
||||||
'fields': ('privacy_policy_en', 'privacy_policy_ar', 'terms_conditions_en', 'terms_conditions_ar')
|
'fields': ('privacy_policy_en', 'privacy_policy_ar', 'terms_conditions_en', 'terms_conditions_ar')
|
||||||
}),
|
}),
|
||||||
(_('Payment Configuration'), {
|
|
||||||
'fields': ('enable_payment',)
|
|
||||||
}),
|
|
||||||
(_('WhatsApp Configuration (Wablas Gateway)'), {
|
(_('WhatsApp Configuration (Wablas Gateway)'), {
|
||||||
'fields': ('whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_business_phone_number_id'),
|
'fields': ('whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_business_phone_number_id'),
|
||||||
'description': _('Configure your Wablas API connection. Use "Test WhatsApp Configuration" to verify.')
|
'description': _('Configure your Wablas API connection. Use "Test WhatsApp Configuration" to verify.')
|
||||||
@ -238,6 +265,20 @@ class PlatformProfileAdmin(admin.ModelAdmin):
|
|||||||
fieldsets += ((_('Tools'), {'fields': ('test_connection_link',)}),)
|
fieldsets += ((_('Tools'), {'fields': ('test_connection_link',)}),)
|
||||||
return fieldsets
|
return fieldsets
|
||||||
|
|
||||||
|
class PricingRuleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('distance_range', 'weight_range', 'price')
|
||||||
|
list_filter = ('min_distance', 'min_weight')
|
||||||
|
search_fields = ('price',)
|
||||||
|
ordering = ('min_distance', 'min_weight')
|
||||||
|
|
||||||
|
def distance_range(self, obj):
|
||||||
|
return f"{obj.min_distance} - {obj.max_distance} km"
|
||||||
|
distance_range.short_description = _("Distance Range")
|
||||||
|
|
||||||
|
def weight_range(self, obj):
|
||||||
|
return f"{obj.min_weight} - {obj.max_weight} kg"
|
||||||
|
weight_range.short_description = _("Weight Range")
|
||||||
|
|
||||||
class CountryAdmin(admin.ModelAdmin):
|
class CountryAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name_en', 'name_ar', 'phone_code')
|
list_display = ('name_en', 'name_ar', 'phone_code')
|
||||||
search_fields = ('name_en', 'name_ar', 'phone_code')
|
search_fields = ('name_en', 'name_ar', 'phone_code')
|
||||||
@ -257,6 +298,7 @@ admin.site.register(City)
|
|||||||
admin.site.register(PlatformProfile, PlatformProfileAdmin)
|
admin.site.register(PlatformProfile, PlatformProfileAdmin)
|
||||||
admin.site.register(Testimonial, TestimonialAdmin)
|
admin.site.register(Testimonial, TestimonialAdmin)
|
||||||
admin.site.register(DriverRating)
|
admin.site.register(DriverRating)
|
||||||
|
admin.site.register(PricingRule, PricingRuleAdmin)
|
||||||
class NotificationTemplateAdmin(admin.ModelAdmin):
|
class NotificationTemplateAdmin(admin.ModelAdmin):
|
||||||
list_display = ('key', 'description')
|
list_display = ('key', 'description')
|
||||||
readonly_fields = ('key', 'description', 'available_variables')
|
readonly_fields = ('key', 'description', 'available_variables')
|
||||||
|
|||||||
@ -6,6 +6,8 @@ from rest_framework.views import APIView
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from .models import Parcel, Profile
|
from .models import Parcel, Profile
|
||||||
from .serializers import ParcelSerializer, ProfileSerializer, PublicParcelSerializer
|
from .serializers import ParcelSerializer, ProfileSerializer, PublicParcelSerializer
|
||||||
|
from .pricing import calculate_haversine_distance, get_pricing_breakdown
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
class CustomAuthToken(ObtainAuthToken):
|
class CustomAuthToken(ObtainAuthToken):
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
@ -87,4 +89,44 @@ class PublicParcelTrackView(generics.RetrieveAPIView):
|
|||||||
serializer_class = PublicParcelSerializer
|
serializer_class = PublicParcelSerializer
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
queryset = Parcel.objects.all()
|
queryset = Parcel.objects.all()
|
||||||
lookup_field = 'tracking_number'
|
lookup_field = 'tracking_number'
|
||||||
|
|
||||||
|
class PriceCalculatorView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny] # Allow frontend to query without strict auth if needed, or IsAuthenticated
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
data = request.data
|
||||||
|
pickup_lat = data.get('pickup_lat')
|
||||||
|
pickup_lng = data.get('pickup_lng')
|
||||||
|
delivery_lat = data.get('delivery_lat')
|
||||||
|
delivery_lng = data.get('delivery_lng')
|
||||||
|
weight = data.get('weight')
|
||||||
|
|
||||||
|
if not all([pickup_lat, pickup_lng, delivery_lat, delivery_lng, weight]):
|
||||||
|
return Response({'error': 'Missing location or weight data.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
weight = Decimal(str(weight))
|
||||||
|
|
||||||
|
# Calculate Distance
|
||||||
|
distance_km = calculate_haversine_distance(pickup_lat, pickup_lng, delivery_lat, delivery_lng)
|
||||||
|
|
||||||
|
# Get Breakdown
|
||||||
|
breakdown = get_pricing_breakdown(distance_km, weight)
|
||||||
|
|
||||||
|
if 'error' in breakdown:
|
||||||
|
return Response(breakdown, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'distance_km': round(float(distance_km), 2),
|
||||||
|
'weight_kg': float(weight),
|
||||||
|
'price': float(breakdown['price']),
|
||||||
|
'platform_fee': float(breakdown['platform_fee']),
|
||||||
|
'driver_amount': float(breakdown['driver_amount']),
|
||||||
|
'platform_fee_percentage': float(breakdown['platform_fee_percentage']),
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(response_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|||||||
@ -238,19 +238,31 @@ class ParcelForm(forms.ModelForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'description', 'weight', 'price',
|
'description', 'weight', 'price',
|
||||||
'pickup_country', 'pickup_governate', 'pickup_city', 'pickup_address',
|
'pickup_country', 'pickup_governate', 'pickup_city', 'pickup_address',
|
||||||
|
'pickup_lat', 'pickup_lng',
|
||||||
'delivery_country', 'delivery_governate', 'delivery_city', 'delivery_address',
|
'delivery_country', 'delivery_governate', 'delivery_city', 'delivery_address',
|
||||||
|
'delivery_lat', 'delivery_lng',
|
||||||
|
'distance_km', 'platform_fee', 'driver_amount', 'platform_fee_percentage',
|
||||||
'receiver_name', 'receiver_phone'
|
'receiver_name', 'receiver_phone'
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control', 'placeholder': _('What are you sending?')}),
|
'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control', 'placeholder': _('What are you sending?')}),
|
||||||
'weight': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
'weight': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
|
||||||
'price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
|
'price': forms.TextInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
|
||||||
|
|
||||||
'pickup_country': forms.Select(attrs={'class': 'form-control'}),
|
'pickup_country': forms.Select(attrs={'class': 'form-control'}),
|
||||||
'pickup_governate': forms.Select(attrs={'class': 'form-control'}),
|
'pickup_governate': forms.Select(attrs={'class': 'form-control'}),
|
||||||
'pickup_city': forms.Select(attrs={'class': 'form-control'}),
|
'pickup_city': forms.Select(attrs={'class': 'form-control'}),
|
||||||
'pickup_address': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Street/Building')}),
|
'pickup_address': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Street/Building')}),
|
||||||
|
|
||||||
|
'pickup_lat': forms.HiddenInput(),
|
||||||
|
'pickup_lng': forms.HiddenInput(),
|
||||||
|
'delivery_lat': forms.HiddenInput(),
|
||||||
|
'delivery_lng': forms.HiddenInput(),
|
||||||
|
'distance_km': forms.HiddenInput(),
|
||||||
|
'platform_fee': forms.HiddenInput(),
|
||||||
|
'driver_amount': forms.HiddenInput(),
|
||||||
|
'platform_fee_percentage': forms.HiddenInput(),
|
||||||
|
|
||||||
'delivery_country': forms.Select(attrs={'class': 'form-control'}),
|
'delivery_country': forms.Select(attrs={'class': 'form-control'}),
|
||||||
'delivery_governate': forms.Select(attrs={'class': 'form-control'}),
|
'delivery_governate': forms.Select(attrs={'class': 'form-control'}),
|
||||||
'delivery_city': forms.Select(attrs={'class': 'form-control'}),
|
'delivery_city': forms.Select(attrs={'class': 'form-control'}),
|
||||||
@ -262,7 +274,7 @@ class ParcelForm(forms.ModelForm):
|
|||||||
labels = {
|
labels = {
|
||||||
'description': _('Package Description'),
|
'description': _('Package Description'),
|
||||||
'weight': _('Weight (kg)'),
|
'weight': _('Weight (kg)'),
|
||||||
'price': _('Your Offer Price (Bid) (OMR)'),
|
'price': _('Calculated Price (OMR)'),
|
||||||
'pickup_country': _('Pickup Country'),
|
'pickup_country': _('Pickup Country'),
|
||||||
'pickup_governate': _('Pickup Governate'),
|
'pickup_governate': _('Pickup Governate'),
|
||||||
'pickup_city': _('Pickup City'),
|
'pickup_city': _('Pickup City'),
|
||||||
@ -377,4 +389,4 @@ class DriverRatingForm(forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Reverse choices for CSS star rating logic (5 to 1) to ensure left-to-right filling
|
# Reverse choices for CSS star rating logic (5 to 1) to ensure left-to-right filling
|
||||||
self.fields['rating'].choices = [(i, str(i)) for i in range(5, 0, -1)]
|
self.fields['rating'].choices = [(i, str(i)) for i in range(5, 0, -1)]
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-01-31 02:03
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0022_notificationtemplate'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PricingRule',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('min_distance', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Min Distance (km)')),
|
||||||
|
('max_distance', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Max Distance (km)')),
|
||||||
|
('min_weight', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Min Weight (kg)')),
|
||||||
|
('max_weight', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Max Weight (kg)')),
|
||||||
|
('price', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Price (OMR)')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Pricing Rule',
|
||||||
|
'verbose_name_plural': 'Pricing Rules',
|
||||||
|
'ordering': ['min_distance', 'min_weight'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parcel',
|
||||||
|
name='delivery_lat',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=16, max_digits=20, null=True, verbose_name='Delivery Latitude'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parcel',
|
||||||
|
name='delivery_lng',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=16, max_digits=20, null=True, verbose_name='Delivery Longitude'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parcel',
|
||||||
|
name='distance_km',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Distance (km)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parcel',
|
||||||
|
name='driver_amount',
|
||||||
|
field=models.DecimalField(decimal_places=3, default=Decimal('0.000'), max_digits=10, verbose_name='Driver Amount (OMR)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parcel',
|
||||||
|
name='pickup_lat',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=16, max_digits=20, null=True, verbose_name='Pickup Latitude'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parcel',
|
||||||
|
name='pickup_lng',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=16, max_digits=20, null=True, verbose_name='Pickup Longitude'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parcel',
|
||||||
|
name='platform_fee',
|
||||||
|
field=models.DecimalField(decimal_places=3, default=Decimal('0.000'), max_digits=10, verbose_name='Platform Fee (OMR)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='parcel',
|
||||||
|
name='platform_fee_percentage',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=5, verbose_name='Fee Percentage (%)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='platformprofile',
|
||||||
|
name='google_maps_api_key',
|
||||||
|
field=models.CharField(blank=True, help_text='API Key for Google Maps (Distance Matrix, Maps JS).', max_length=255, verbose_name='Google Maps API Key'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='platformprofile',
|
||||||
|
name='platform_fee_percentage',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Percentage deducted from total trip price.', max_digits=5, verbose_name='Platform Fee (%)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='parcel',
|
||||||
|
name='price',
|
||||||
|
field=models.DecimalField(decimal_places=3, default=Decimal('0.000'), max_digits=10, verbose_name='Total Price (OMR)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
Binary file not shown.
@ -7,6 +7,7 @@ from django.dispatch import receiver
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
import uuid
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
class Country(models.Model):
|
class Country(models.Model):
|
||||||
name_en = models.CharField(_('Name (English)'), max_length=100)
|
name_en = models.CharField(_('Name (English)'), max_length=100)
|
||||||
@ -115,6 +116,21 @@ def save_user_profile(sender, instance, **kwargs):
|
|||||||
if hasattr(instance, 'profile'):
|
if hasattr(instance, 'profile'):
|
||||||
instance.profile.save()
|
instance.profile.save()
|
||||||
|
|
||||||
|
class PricingRule(models.Model):
|
||||||
|
min_distance = models.DecimalField(_('Min Distance (km)'), max_digits=10, decimal_places=2)
|
||||||
|
max_distance = models.DecimalField(_('Max Distance (km)'), max_digits=10, decimal_places=2)
|
||||||
|
min_weight = models.DecimalField(_('Min Weight (kg)'), max_digits=10, decimal_places=2)
|
||||||
|
max_weight = models.DecimalField(_('Max Weight (kg)'), max_digits=10, decimal_places=2)
|
||||||
|
price = models.DecimalField(_('Price (OMR)'), max_digits=10, decimal_places=3)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.min_distance}-{self.max_distance}km | {self.min_weight}-{self.max_weight}kg = {self.price} OMR"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Pricing Rule')
|
||||||
|
verbose_name_plural = _('Pricing Rules')
|
||||||
|
ordering = ['min_distance', 'min_weight']
|
||||||
|
|
||||||
class Parcel(models.Model):
|
class Parcel(models.Model):
|
||||||
STATUS_CHOICES = (
|
STATUS_CHOICES = (
|
||||||
('pending', _('Pending Pickup')),
|
('pending', _('Pending Pickup')),
|
||||||
@ -136,19 +152,31 @@ class Parcel(models.Model):
|
|||||||
|
|
||||||
description = models.TextField(_('Description'))
|
description = models.TextField(_('Description'))
|
||||||
weight = models.DecimalField(_('Weight (kg)'), max_digits=5, decimal_places=2, help_text=_("Weight in kg"))
|
weight = models.DecimalField(_('Weight (kg)'), max_digits=5, decimal_places=2, help_text=_("Weight in kg"))
|
||||||
price = models.DecimalField(_('Price (OMR)'), max_digits=10, decimal_places=3, default=0.000)
|
price = models.DecimalField(_('Total Price (OMR)'), max_digits=10, decimal_places=3, default=Decimal('0.000'))
|
||||||
|
|
||||||
|
# Financial Breakdown
|
||||||
|
platform_fee = models.DecimalField(_('Platform Fee (OMR)'), max_digits=10, decimal_places=3, default=Decimal('0.000'))
|
||||||
|
platform_fee_percentage = models.DecimalField(_('Fee Percentage (%)'), max_digits=5, decimal_places=2, default=Decimal('0.00'))
|
||||||
|
driver_amount = models.DecimalField(_('Driver Amount (OMR)'), max_digits=10, decimal_places=3, default=Decimal('0.000'))
|
||||||
|
|
||||||
|
# Trip Info
|
||||||
|
distance_km = models.DecimalField(_('Distance (km)'), max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
||||||
|
|
||||||
# Pickup Location
|
# Pickup Location
|
||||||
pickup_country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup Country'))
|
pickup_country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup Country'))
|
||||||
pickup_governate = models.ForeignKey(Governate, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup Governate'))
|
pickup_governate = models.ForeignKey(Governate, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup Governate'))
|
||||||
pickup_city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup City'))
|
pickup_city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True, blank=True, related_name='pickup_parcels', verbose_name=_('Pickup City'))
|
||||||
pickup_address = models.CharField(_('Pickup Address'), max_length=255)
|
pickup_address = models.CharField(_('Pickup Address'), max_length=255)
|
||||||
|
pickup_lat = models.DecimalField(_('Pickup Latitude'), max_digits=20, decimal_places=16, null=True, blank=True)
|
||||||
|
pickup_lng = models.DecimalField(_('Pickup Longitude'), max_digits=20, decimal_places=16, null=True, blank=True)
|
||||||
|
|
||||||
# Delivery Location
|
# Delivery Location
|
||||||
delivery_country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_parcels', verbose_name=_('Delivery Country'))
|
delivery_country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_parcels', verbose_name=_('Delivery Country'))
|
||||||
delivery_governate = models.ForeignKey(Governate, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_parcels', verbose_name=_('Delivery Governate'))
|
delivery_governate = models.ForeignKey(Governate, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_parcels', verbose_name=_('Delivery Governate'))
|
||||||
delivery_city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_city_parcels', verbose_name=_('Delivery City'))
|
delivery_city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True, blank=True, related_name='delivery_city_parcels', verbose_name=_('Delivery City'))
|
||||||
delivery_address = models.CharField(_('Delivery Address'), max_length=255)
|
delivery_address = models.CharField(_('Delivery Address'), max_length=255)
|
||||||
|
delivery_lat = models.DecimalField(_('Delivery Latitude'), max_digits=20, decimal_places=16, null=True, blank=True)
|
||||||
|
delivery_lng = models.DecimalField(_('Delivery Longitude'), max_digits=20, decimal_places=16, null=True, blank=True)
|
||||||
|
|
||||||
receiver_name = models.CharField(_('Receiver Name'), max_length=100)
|
receiver_name = models.CharField(_('Receiver Name'), max_length=100)
|
||||||
receiver_phone = models.CharField(_('Receiver Phone'), max_length=20)
|
receiver_phone = models.CharField(_('Receiver Phone'), max_length=20)
|
||||||
@ -181,6 +209,12 @@ class PlatformProfile(models.Model):
|
|||||||
registration_number = models.CharField(_('Registration Number'), max_length=100, blank=True)
|
registration_number = models.CharField(_('Registration Number'), max_length=100, blank=True)
|
||||||
vat_number = models.CharField(_('VAT Number'), max_length=100, blank=True)
|
vat_number = models.CharField(_('VAT Number'), max_length=100, blank=True)
|
||||||
|
|
||||||
|
# Financial Configuration
|
||||||
|
platform_fee_percentage = models.DecimalField(_('Platform Fee (%)'), max_digits=5, decimal_places=2, default=Decimal('0.00'), help_text=_("Percentage deducted from total trip price."))
|
||||||
|
|
||||||
|
# Integrations
|
||||||
|
google_maps_api_key = models.CharField(_('Google Maps API Key'), max_length=255, blank=True, help_text=_("API Key for Google Maps (Distance Matrix, Maps JS)."))
|
||||||
|
|
||||||
# Bilingual Policies
|
# Bilingual Policies
|
||||||
privacy_policy_en = models.TextField(_('Privacy Policy (English)'), blank=True)
|
privacy_policy_en = models.TextField(_('Privacy Policy (English)'), blank=True)
|
||||||
privacy_policy_ar = models.TextField(_('Privacy Policy (Arabic)'), blank=True)
|
privacy_policy_ar = models.TextField(_('Privacy Policy (Arabic)'), blank=True)
|
||||||
|
|||||||
70
core/pricing.py
Normal file
70
core/pricing.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
import math
|
||||||
|
from .models import PricingRule, PlatformProfile
|
||||||
|
|
||||||
|
def calculate_haversine_distance(lat1, lon1, lat2, lon2):
|
||||||
|
"""
|
||||||
|
Calculate the great circle distance in kilometers between two points
|
||||||
|
on the earth (specified in decimal degrees)
|
||||||
|
"""
|
||||||
|
if lat1 is None or lon1 is None or lat2 is None or lon2 is None:
|
||||||
|
return Decimal('0.00')
|
||||||
|
|
||||||
|
# Convert decimal degrees to radians
|
||||||
|
lat1, lon1, lat2, lon2 = map(float, [lat1, lon1, lat2, lon2])
|
||||||
|
|
||||||
|
# Haversine formula
|
||||||
|
dlon = lon2 - lon1
|
||||||
|
dlat = lat2 - lat1
|
||||||
|
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||||
|
c = 2 * math.asin(math.sqrt(a))
|
||||||
|
r = 6371 # Radius of earth in kilometers. Use 3956 for miles
|
||||||
|
|
||||||
|
return Decimal(c * r)
|
||||||
|
|
||||||
|
def get_pricing_breakdown(distance_km, weight_kg):
|
||||||
|
"""
|
||||||
|
Returns a dictionary with pricing breakdown:
|
||||||
|
{
|
||||||
|
'price': Decimal,
|
||||||
|
'platform_fee': Decimal,
|
||||||
|
'platform_fee_percentage': Decimal,
|
||||||
|
'driver_amount': Decimal,
|
||||||
|
'error': str (optional)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 1. Find matching rule
|
||||||
|
# We look for a rule that covers this distance and weight
|
||||||
|
rule = PricingRule.objects.filter(
|
||||||
|
min_distance__lte=distance_km,
|
||||||
|
max_distance__gte=distance_km,
|
||||||
|
min_weight__lte=weight_kg,
|
||||||
|
max_weight__gte=weight_kg
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not rule:
|
||||||
|
# Fallback or Error
|
||||||
|
# Try to find a rule just by distance if weight is slightly off? No, strict for now.
|
||||||
|
return {
|
||||||
|
'price': Decimal('0.000'),
|
||||||
|
'platform_fee': Decimal('0.000'),
|
||||||
|
'platform_fee_percentage': Decimal('0.00'),
|
||||||
|
'driver_amount': Decimal('0.000'),
|
||||||
|
'error': 'No pricing rule found for this distance/weight combination.'
|
||||||
|
}
|
||||||
|
|
||||||
|
total_price = rule.price
|
||||||
|
|
||||||
|
# 2. Calculate Fees
|
||||||
|
profile = PlatformProfile.objects.first()
|
||||||
|
fee_percentage = profile.platform_fee_percentage if profile else Decimal('0.00')
|
||||||
|
|
||||||
|
platform_fee = total_price * (fee_percentage / Decimal('100.00'))
|
||||||
|
driver_amount = total_price - platform_fee
|
||||||
|
|
||||||
|
return {
|
||||||
|
'price': total_price,
|
||||||
|
'platform_fee': platform_fee,
|
||||||
|
'platform_fee_percentage': fee_percentage,
|
||||||
|
'driver_amount': driver_amount
|
||||||
|
}
|
||||||
@ -15,8 +15,26 @@
|
|||||||
|
|
||||||
<div class="card border-0 shadow-sm p-4" style="border-radius: 20px;">
|
<div class="card border-0 shadow-sm p-4" style="border-radius: 20px;">
|
||||||
<h2 class="mb-4">{% trans "Request a Shipment" %}</h2>
|
<h2 class="mb-4">{% trans "Request a Shipment" %}</h2>
|
||||||
<form method="POST">
|
|
||||||
|
{% if not google_maps_api_key %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{% trans "Map integration is disabled (API Key missing). Distance must be entered manually or functionality is limited." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" id="shipmentForm">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Hidden Fields -->
|
||||||
|
{{ form.pickup_lat }}
|
||||||
|
{{ form.pickup_lng }}
|
||||||
|
{{ form.delivery_lat }}
|
||||||
|
{{ form.delivery_lng }}
|
||||||
|
{{ form.distance_km }}
|
||||||
|
{{ form.platform_fee }}
|
||||||
|
{{ form.driver_amount }}
|
||||||
|
{{ form.platform_fee_percentage }}
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<!-- General Info -->
|
<!-- General Info -->
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@ -36,6 +54,7 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="{{ form.price.id_for_label }}">{{ form.price.label }}</label>
|
<label class="form-label" for="{{ form.price.id_for_label }}">{{ form.price.label }}</label>
|
||||||
{{ form.price }}
|
{{ form.price }}
|
||||||
|
<small class="text-muted">{% trans "Calculated automatically based on distance and weight." %}</small>
|
||||||
{% if form.price.errors %}
|
{% if form.price.errors %}
|
||||||
<div class="text-danger small">{{ form.price.errors }}</div>
|
<div class="text-danger small">{{ form.price.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -43,68 +62,58 @@
|
|||||||
|
|
||||||
<!-- Pickup Details -->
|
<!-- Pickup Details -->
|
||||||
<div class="col-12 mt-4">
|
<div class="col-12 mt-4">
|
||||||
<h4 class="mb-3 text-secondary border-bottom pb-2">{% trans "Pickup Details" %}</h4>
|
<h4 class="mb-3 text-secondary border-bottom pb-2 d-flex justify-content-between align-items-center">
|
||||||
|
{% trans "Pickup Details" %}
|
||||||
|
{% if google_maps_api_key %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openMap('pickup')">
|
||||||
|
<i class="bi bi-geo-alt-fill"></i> {% trans "Select on Map" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="{{ form.pickup_country.id_for_label }}">{{ form.pickup_country.label }}</label>
|
<label class="form-label" for="{{ form.pickup_country.id_for_label }}">{{ form.pickup_country.label }}</label>
|
||||||
{{ form.pickup_country }}
|
{{ form.pickup_country }}
|
||||||
{% if form.pickup_country.errors %}
|
|
||||||
<div class="text-danger small">{{ form.pickup_country.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="{{ form.pickup_governate.id_for_label }}">{{ form.pickup_governate.label }}</label>
|
<label class="form-label" for="{{ form.pickup_governate.id_for_label }}">{{ form.pickup_governate.label }}</label>
|
||||||
{{ form.pickup_governate }}
|
{{ form.pickup_governate }}
|
||||||
{% if form.pickup_governate.errors %}
|
|
||||||
<div class="text-danger small">{{ form.pickup_governate.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="{{ form.pickup_city.id_for_label }}">{{ form.pickup_city.label }}</label>
|
<label class="form-label" for="{{ form.pickup_city.id_for_label }}">{{ form.pickup_city.label }}</label>
|
||||||
{{ form.pickup_city }}
|
{{ form.pickup_city }}
|
||||||
{% if form.pickup_city.errors %}
|
|
||||||
<div class="text-danger small">{{ form.pickup_city.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="{{ form.pickup_address.id_for_label }}">{{ form.pickup_address.label }}</label>
|
<label class="form-label" for="{{ form.pickup_address.id_for_label }}">{{ form.pickup_address.label }}</label>
|
||||||
{{ form.pickup_address }}
|
{{ form.pickup_address }}
|
||||||
{% if form.pickup_address.errors %}
|
|
||||||
<div class="text-danger small">{{ form.pickup_address.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delivery Details -->
|
<!-- Delivery Details -->
|
||||||
<div class="col-12 mt-4">
|
<div class="col-12 mt-4">
|
||||||
<h4 class="mb-3 text-secondary border-bottom pb-2">{% trans "Delivery Details" %}</h4>
|
<h4 class="mb-3 text-secondary border-bottom pb-2 d-flex justify-content-between align-items-center">
|
||||||
|
{% trans "Delivery Details" %}
|
||||||
|
{% if google_maps_api_key %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openMap('delivery')">
|
||||||
|
<i class="bi bi-geo-alt-fill"></i> {% trans "Select on Map" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="{{ form.delivery_country.id_for_label }}">{{ form.delivery_country.label }}</label>
|
<label class="form-label" for="{{ form.delivery_country.id_for_label }}">{{ form.delivery_country.label }}</label>
|
||||||
{{ form.delivery_country }}
|
{{ form.delivery_country }}
|
||||||
{% if form.delivery_country.errors %}
|
|
||||||
<div class="text-danger small">{{ form.delivery_country.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="{{ form.delivery_governate.id_for_label }}">{{ form.delivery_governate.label }}</label>
|
<label class="form-label" for="{{ form.delivery_governate.id_for_label }}">{{ form.delivery_governate.label }}</label>
|
||||||
{{ form.delivery_governate }}
|
{{ form.delivery_governate }}
|
||||||
{% if form.delivery_governate.errors %}
|
|
||||||
<div class="text-danger small">{{ form.delivery_governate.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="{{ form.delivery_city.id_for_label }}">{{ form.delivery_city.label }}</label>
|
<label class="form-label" for="{{ form.delivery_city.id_for_label }}">{{ form.delivery_city.label }}</label>
|
||||||
{{ form.delivery_city }}
|
{{ form.delivery_city }}
|
||||||
{% if form.delivery_city.errors %}
|
|
||||||
<div class="text-danger small">{{ form.delivery_city.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="{{ form.delivery_address.id_for_label }}">{{ form.delivery_address.label }}</label>
|
<label class="form-label" for="{{ form.delivery_address.id_for_label }}">{{ form.delivery_address.label }}</label>
|
||||||
{{ form.delivery_address }}
|
{{ form.delivery_address }}
|
||||||
{% if form.delivery_address.errors %}
|
|
||||||
<div class="text-danger small">{{ form.delivery_address.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Receiver Details -->
|
<!-- Receiver Details -->
|
||||||
@ -114,9 +123,6 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="{{ form.receiver_name.id_for_label }}">{{ form.receiver_name.label }}</label>
|
<label class="form-label" for="{{ form.receiver_name.id_for_label }}">{{ form.receiver_name.label }}</label>
|
||||||
{{ form.receiver_name }}
|
{{ form.receiver_name }}
|
||||||
{% if form.receiver_name.errors %}
|
|
||||||
<div class="text-danger small">{{ form.receiver_name.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="{{ form.receiver_phone.id_for_label }}">{{ form.receiver_phone.label }}</label>
|
<label class="form-label" for="{{ form.receiver_phone.id_for_label }}">{{ form.receiver_phone.label }}</label>
|
||||||
@ -128,12 +134,6 @@
|
|||||||
{{ form.receiver_phone }}
|
{{ form.receiver_phone }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if form.receiver_phone_code.errors %}
|
|
||||||
<div class="text-danger small">{{ form.receiver_phone_code.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if form.receiver_phone.errors %}
|
|
||||||
<div class="text-danger small">{{ form.receiver_phone.errors }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 mt-4 d-flex gap-2">
|
<div class="col-12 mt-4 d-flex gap-2">
|
||||||
@ -147,6 +147,159 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Modal -->
|
||||||
|
<div class="modal fade" id="mapModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{% trans "Choose Location" %}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0">
|
||||||
|
<div id="map" style="height: 450px; width: 100%;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="confirmLocation()">{% trans "Confirm Location" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if google_maps_api_key %}
|
||||||
|
<script src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&libraries=places"></script>
|
||||||
|
<script>
|
||||||
|
let map;
|
||||||
|
let marker;
|
||||||
|
let currentMode = 'pickup'; // 'pickup' or 'delivery'
|
||||||
|
let mapModal;
|
||||||
|
|
||||||
|
function initMap() {
|
||||||
|
const defaultLocation = { lat: 23.5880, lng: 58.3829 }; // Muscat, Oman
|
||||||
|
|
||||||
|
map = new google.maps.Map(document.getElementById("map"), {
|
||||||
|
zoom: 12,
|
||||||
|
center: defaultLocation,
|
||||||
|
});
|
||||||
|
|
||||||
|
marker = new google.maps.Marker({
|
||||||
|
map: map,
|
||||||
|
draggable: true,
|
||||||
|
animation: google.maps.Animation.DROP,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addListener("click", (e) => {
|
||||||
|
placeMarkerAndPanTo(e.latLng, map);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeMarkerAndPanTo(latLng, map) {
|
||||||
|
marker.setPosition(latLng);
|
||||||
|
map.panTo(latLng);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMap(mode) {
|
||||||
|
currentMode = mode;
|
||||||
|
mapModal = new bootstrap.Modal(document.getElementById('mapModal'));
|
||||||
|
mapModal.show();
|
||||||
|
|
||||||
|
// Resize map when modal opens
|
||||||
|
document.getElementById('mapModal').addEventListener('shown.bs.modal', function () {
|
||||||
|
google.maps.event.trigger(map, "resize");
|
||||||
|
|
||||||
|
// Set marker if existing value
|
||||||
|
let latField = document.getElementById(`id_${mode}_lat`);
|
||||||
|
let lngField = document.getElementById(`id_${mode}_lng`);
|
||||||
|
|
||||||
|
if (latField.value && lngField.value) {
|
||||||
|
let loc = { lat: parseFloat(latField.value), lng: parseFloat(lngField.value) };
|
||||||
|
marker.setPosition(loc);
|
||||||
|
map.setCenter(loc);
|
||||||
|
} else {
|
||||||
|
// Default to Muscat if no value
|
||||||
|
map.setCenter({ lat: 23.5880, lng: 58.3829 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmLocation() {
|
||||||
|
if (!marker.getPosition()) return;
|
||||||
|
|
||||||
|
const lat = marker.getPosition().lat();
|
||||||
|
const lng = marker.getPosition().lng();
|
||||||
|
|
||||||
|
document.getElementById(`id_${currentMode}_lat`).value = lat;
|
||||||
|
document.getElementById(`id_${currentMode}_lng`).value = lng;
|
||||||
|
|
||||||
|
// Try reverse geocoding to fill address (optional but nice)
|
||||||
|
const geocoder = new google.maps.Geocoder();
|
||||||
|
geocoder.geocode({ location: { lat: lat, lng: lng } }, (results, status) => {
|
||||||
|
if (status === "OK" && results[0]) {
|
||||||
|
// Simple fill of address field if empty
|
||||||
|
const addrField = document.getElementById(`id_${currentMode}_address`);
|
||||||
|
if (!addrField.value) {
|
||||||
|
addrField.value = results[0].formatted_address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
const modalEl = document.getElementById('mapModal');
|
||||||
|
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Trigger Price Calculation
|
||||||
|
calculatePrice();
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePrice() {
|
||||||
|
const pickupLat = document.getElementById('id_pickup_lat').value;
|
||||||
|
const pickupLng = document.getElementById('id_pickup_lng').value;
|
||||||
|
const deliveryLat = document.getElementById('id_delivery_lat').value;
|
||||||
|
const deliveryLng = document.getElementById('id_delivery_lng').value;
|
||||||
|
const weight = document.getElementById('id_weight').value;
|
||||||
|
|
||||||
|
if (pickupLat && pickupLng && deliveryLat && deliveryLng && weight) {
|
||||||
|
fetch('{% url "api_calculate_price" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
pickup_lat: pickupLat,
|
||||||
|
pickup_lng: pickupLng,
|
||||||
|
delivery_lat: deliveryLat,
|
||||||
|
delivery_lng: deliveryLng,
|
||||||
|
weight: weight
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
// console.error(data.error);
|
||||||
|
// Maybe show error to user?
|
||||||
|
// alert(data.error);
|
||||||
|
} else {
|
||||||
|
document.getElementById('id_price').value = data.price;
|
||||||
|
document.getElementById('id_distance_km').value = data.distance_km;
|
||||||
|
document.getElementById('id_platform_fee').value = data.platform_fee;
|
||||||
|
document.getElementById('id_driver_amount').value = data.driver_amount;
|
||||||
|
document.getElementById('id_platform_fee_percentage').value = data.platform_fee_percentage;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach listener to weight
|
||||||
|
document.getElementById('id_weight').addEventListener('change', calculatePrice);
|
||||||
|
document.getElementById('id_weight').addEventListener('keyup', calculatePrice);
|
||||||
|
|
||||||
|
window.initMap = initMap;
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
function setupDependentDropdowns(countryId, governateId, cityId) {
|
function setupDependentDropdowns(countryId, governateId, cityId) {
|
||||||
|
|||||||
@ -78,6 +78,7 @@ urlpatterns = [
|
|||||||
path('api/parcels/<int:pk>/', api_views.ParcelDetailView.as_view(), name='api_parcel_detail'),
|
path('api/parcels/<int:pk>/', api_views.ParcelDetailView.as_view(), name='api_parcel_detail'),
|
||||||
path('api/track/<str:tracking_number>/', api_views.PublicParcelTrackView.as_view(), name='api_track_parcel'),
|
path('api/track/<str:tracking_number>/', api_views.PublicParcelTrackView.as_view(), name='api_track_parcel'),
|
||||||
path('api/profile/', api_views.UserProfileView.as_view(), name='api_user_profile'),
|
path('api/profile/', api_views.UserProfileView.as_view(), name='api_user_profile'),
|
||||||
|
path('api/calculate-price/', api_views.PriceCalculatorView.as_view(), name='api_calculate_price'),
|
||||||
|
|
||||||
# Aliases for mobile app compatibility (API v1)
|
# Aliases for mobile app compatibility (API v1)
|
||||||
path('api/shipments/', api_views.ParcelListCreateView.as_view(), name='api_shipment_list'),
|
path('api/shipments/', api_views.ParcelListCreateView.as_view(), name='api_shipment_list'),
|
||||||
@ -86,4 +87,4 @@ urlpatterns = [
|
|||||||
# Root-level Aliases (for apps hardcoded to /shipments/)
|
# Root-level Aliases (for apps hardcoded to /shipments/)
|
||||||
path('shipments/', api_views.ParcelListCreateView.as_view(), name='root_shipment_list'),
|
path('shipments/', api_views.ParcelListCreateView.as_view(), name='root_shipment_list'),
|
||||||
path('shipments/<int:pk>/', api_views.ParcelDetailView.as_view(), name='root_shipment_detail'),
|
path('shipments/<int:pk>/', api_views.ParcelDetailView.as_view(), name='root_shipment_detail'),
|
||||||
]
|
]
|
||||||
@ -35,6 +35,7 @@ import weasyprint
|
|||||||
import qrcode
|
import qrcode
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import base64
|
import base64
|
||||||
|
from .pricing import get_pricing_breakdown # Import pricing logic
|
||||||
|
|
||||||
def index(request):
|
def index(request):
|
||||||
# If tracking_id is present, redirect to the new track view
|
# If tracking_id is present, redirect to the new track view
|
||||||
@ -295,6 +296,17 @@ def shipment_request(request):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
parcel = form.save(commit=False)
|
parcel = form.save(commit=False)
|
||||||
parcel.shipper = request.user
|
parcel.shipper = request.user
|
||||||
|
|
||||||
|
# Recalculate price on backend to ensure integrity
|
||||||
|
# We trust the form's distance/weight if populated, but good to verify
|
||||||
|
# Ideally we recalculate from PricingRule here too
|
||||||
|
breakdown = get_pricing_breakdown(parcel.distance_km, parcel.weight)
|
||||||
|
if 'error' not in breakdown:
|
||||||
|
parcel.price = breakdown['price']
|
||||||
|
parcel.platform_fee = breakdown['platform_fee']
|
||||||
|
parcel.platform_fee_percentage = breakdown['platform_fee_percentage']
|
||||||
|
parcel.driver_amount = breakdown['driver_amount']
|
||||||
|
|
||||||
parcel.save()
|
parcel.save()
|
||||||
|
|
||||||
# WhatsApp Notification
|
# WhatsApp Notification
|
||||||
@ -304,7 +316,14 @@ def shipment_request(request):
|
|||||||
return redirect('dashboard')
|
return redirect('dashboard')
|
||||||
else:
|
else:
|
||||||
form = ParcelForm()
|
form = ParcelForm()
|
||||||
return render(request, 'core/shipment_request.html', {'form': form})
|
|
||||||
|
platform_profile = PlatformProfile.objects.first()
|
||||||
|
google_maps_api_key = platform_profile.google_maps_api_key if platform_profile else None
|
||||||
|
|
||||||
|
return render(request, 'core/shipment_request.html', {
|
||||||
|
'form': form,
|
||||||
|
'google_maps_api_key': google_maps_api_key
|
||||||
|
})
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def accept_parcel(request, parcel_id):
|
def accept_parcel(request, parcel_id):
|
||||||
@ -939,13 +958,27 @@ def edit_parcel(request, parcel_id):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = ParcelForm(request.POST, instance=parcel)
|
form = ParcelForm(request.POST, instance=parcel)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
parcel_updated = form.save(commit=False)
|
||||||
|
|
||||||
|
# Recalculate if fields changed
|
||||||
|
breakdown = get_pricing_breakdown(parcel_updated.distance_km, parcel_updated.weight)
|
||||||
|
if 'error' not in breakdown:
|
||||||
|
parcel_updated.price = breakdown['price']
|
||||||
|
parcel_updated.platform_fee = breakdown['platform_fee']
|
||||||
|
parcel_updated.platform_fee_percentage = breakdown['platform_fee_percentage']
|
||||||
|
parcel_updated.driver_amount = breakdown['driver_amount']
|
||||||
|
|
||||||
|
parcel_updated.save()
|
||||||
|
|
||||||
messages.success(request, _("Shipment updated successfully."))
|
messages.success(request, _("Shipment updated successfully."))
|
||||||
return redirect('dashboard')
|
return redirect('dashboard')
|
||||||
else:
|
else:
|
||||||
form = ParcelForm(instance=parcel)
|
form = ParcelForm(instance=parcel)
|
||||||
|
|
||||||
|
platform_profile = PlatformProfile.objects.first()
|
||||||
|
google_maps_api_key = platform_profile.google_maps_api_key if platform_profile else None
|
||||||
|
|
||||||
return render(request, 'core/edit_parcel.html', {'form': form, 'parcel': parcel})
|
return render(request, 'core/edit_parcel.html', {'form': form, 'parcel': parcel, 'google_maps_api_key': google_maps_api_key})
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def cancel_parcel(request, parcel_id):
|
def cancel_parcel(request, parcel_id):
|
||||||
@ -1060,4 +1093,4 @@ def verify_2fa_otp(request):
|
|||||||
except OTPVerification.DoesNotExist:
|
except OTPVerification.DoesNotExist:
|
||||||
messages.error(request, _("No valid OTP found. Please request a new one."))
|
messages.error(request, _("No valid OTP found. Please request a new one."))
|
||||||
|
|
||||||
return render(request, 'core/verify_2fa_otp.html')
|
return render(request, 'core/verify_2fa_otp.html')
|
||||||
Loading…
x
Reference in New Issue
Block a user