285 lines
8.6 KiB
TypeScript
285 lines
8.6 KiB
TypeScript
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||
import { createClient } from 'jsr:@supabase/supabase-js@2';
|
||
import { requireAuth, checkRateLimit } from '../_shared/auth.ts';
|
||
|
||
const corsHeaders = {
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||
};
|
||
|
||
interface Place {
|
||
tripPlaceId: string;
|
||
placeId: string;
|
||
name: string;
|
||
lat: number;
|
||
lng: number;
|
||
duration: string;
|
||
order_index: number;
|
||
is_time_fixed?: boolean; // Sabit saatli etkinlik mi?
|
||
fixed_start_time?: string; // Sabit başlangıç saati
|
||
fixed_end_time?: string; // Sabit bitiş saati
|
||
}
|
||
|
||
interface OptimizeRouteRequest {
|
||
dayId: string;
|
||
date: string;
|
||
places: Place[];
|
||
startLocation?: { lat: number; lng: number };
|
||
startLocationType?: 'balloon' | 'hotel' | 'place' | 'city_center'; // Başlangıç noktası tipi
|
||
travelMode?: string;
|
||
maxDayTime?: string;
|
||
}
|
||
|
||
interface OptimizedPlace {
|
||
tripPlaceId: string;
|
||
placeId: string;
|
||
name: string;
|
||
newOrderIndex: number;
|
||
oldOrderIndex: number;
|
||
}
|
||
|
||
interface OptimizationResult {
|
||
optimizedOrder: OptimizedPlace[];
|
||
distanceBeforeKm: number;
|
||
distanceAfterKm: number;
|
||
estimatedSavings: {
|
||
distanceKm: number;
|
||
timeMinutes: number;
|
||
};
|
||
explanation: string;
|
||
}
|
||
|
||
// Haversine formülü ile iki koordinat arasındaki mesafeyi hesapla (km)
|
||
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||
const R = 6371; // Dünya'nın yarıçapı (km)
|
||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||
const a =
|
||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
return R * c;
|
||
}
|
||
|
||
// Toplam rota mesafesini hesapla
|
||
function calculateTotalDistance(places: Place[]): number {
|
||
let total = 0;
|
||
for (let i = 0; i < places.length - 1; i++) {
|
||
total += calculateDistance(
|
||
places[i].lat,
|
||
places[i].lng,
|
||
places[i + 1].lat,
|
||
places[i + 1].lng
|
||
);
|
||
}
|
||
return total;
|
||
}
|
||
|
||
// Nearest Neighbor algoritması ile rotayı optimize et
|
||
// ⚠️ Sabit saatli etkinlikler (balon uçuşu) optimizasyona dahil edilmez
|
||
function optimizeRoute(places: Place[], startLocation?: { lat: number; lng: number }): Place[] {
|
||
if (places.length <= 2) {
|
||
return places; // 2 veya daha az yer varsa optimize etmeye gerek yok
|
||
}
|
||
|
||
// Sabit saatli ve esnek etkinlikleri ayır
|
||
const fixedTimePlaces = places.filter(p => p.is_time_fixed);
|
||
const flexiblePlaces = places.filter(p => !p.is_time_fixed);
|
||
|
||
// Eğer sadece sabit saatli etkinlikler varsa, olduğu gibi döndür
|
||
if (flexiblePlaces.length === 0) {
|
||
return places;
|
||
}
|
||
|
||
// Esnek etkinlikleri optimize et
|
||
const unvisited = [...flexiblePlaces];
|
||
const optimized: Place[] = [];
|
||
|
||
// Başlangıç noktasını belirle
|
||
let current: { lat: number; lng: number };
|
||
|
||
// Eğer sabit saatli etkinlik varsa (örn: balon), onun konumundan başla
|
||
if (fixedTimePlaces.length > 0) {
|
||
const lastFixed = fixedTimePlaces[fixedTimePlaces.length - 1];
|
||
current = { lat: lastFixed.lat, lng: lastFixed.lng };
|
||
} else if (startLocation) {
|
||
current = startLocation;
|
||
} else {
|
||
// Başlangıç noktası yoksa ilk esnek yeri kullan
|
||
const first = unvisited.shift()!;
|
||
optimized.push(first);
|
||
current = { lat: first.lat, lng: first.lng };
|
||
}
|
||
|
||
// En yakın komşu algoritması (sadece esnek etkinlikler için)
|
||
while (unvisited.length > 0) {
|
||
let nearestIndex = 0;
|
||
let nearestDistance = Infinity;
|
||
|
||
// En yakın yeri bul
|
||
for (let i = 0; i < unvisited.length; i++) {
|
||
const distance = calculateDistance(
|
||
current.lat,
|
||
current.lng,
|
||
unvisited[i].lat,
|
||
unvisited[i].lng
|
||
);
|
||
if (distance < nearestDistance) {
|
||
nearestDistance = distance;
|
||
nearestIndex = i;
|
||
}
|
||
}
|
||
|
||
// En yakın yeri ekle
|
||
const nearest = unvisited.splice(nearestIndex, 1)[0];
|
||
optimized.push(nearest);
|
||
current = { lat: nearest.lat, lng: nearest.lng };
|
||
}
|
||
|
||
// Sabit saatli etkinlikleri başa ekle, optimize edilmiş esnek etkinlikleri sonra ekle
|
||
return [...fixedTimePlaces, ...optimized];
|
||
}
|
||
|
||
// Optimizasyon açıklaması oluştur
|
||
function generateExplanation(original: Place[], optimized: Place[]): string {
|
||
const changes: string[] = [];
|
||
|
||
// Sabit saatli etkinlikleri kontrol et
|
||
const hasFixedTime = original.some(p => p.is_time_fixed);
|
||
if (hasFixedTime) {
|
||
changes.push('Sabit saatli etkinlikler (balon uçuşu) korundu');
|
||
}
|
||
|
||
for (let i = 0; i < optimized.length; i++) {
|
||
const newPlace = optimized[i];
|
||
const oldIndex = original.findIndex(p => p.tripPlaceId === newPlace.tripPlaceId);
|
||
|
||
if (oldIndex !== i && !newPlace.is_time_fixed) {
|
||
changes.push(`${newPlace.name} ${oldIndex + 1}. sıradan ${i + 1}. sıraya taşındı`);
|
||
}
|
||
}
|
||
|
||
if (changes.length === 0) {
|
||
return "Mevcut sıralama zaten optimal görünüyor.";
|
||
}
|
||
|
||
return changes.slice(0, 3).join(', ') + (changes.length > 3 ? ' ve diğer değişiklikler' : '') +
|
||
'. Bu değişiklikler geriye dönüşleri azaltarak daha verimli bir rota oluşturur.';
|
||
}
|
||
|
||
Deno.serve(async (req) => {
|
||
// CORS preflight
|
||
if (req.method === 'OPTIONS') {
|
||
return new Response('ok', { headers: corsHeaders });
|
||
}
|
||
|
||
try {
|
||
// Auth check
|
||
const auth = await requireAuth(req);
|
||
if (auth.error) return auth.error;
|
||
|
||
// Rate limit check (20 AI suggestions per hour)
|
||
const supabaseService = createClient(
|
||
Deno.env.get('SUPABASE_URL')!,
|
||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||
);
|
||
const rateLimitResponse = await checkRateLimit(auth.userId, 'ai_suggest', supabaseService);
|
||
if (rateLimitResponse) return rateLimitResponse;
|
||
|
||
const { places, startLocation }: OptimizeRouteRequest = await req.json();
|
||
|
||
// Validasyon
|
||
if (!places || places.length < 2) {
|
||
return new Response(
|
||
JSON.stringify({
|
||
error: 'En az 2 yer gerekli',
|
||
optimizedOrder: [],
|
||
distanceBeforeKm: 0,
|
||
distanceAfterKm: 0,
|
||
estimatedSavings: { distanceKm: 0, timeMinutes: 0 },
|
||
explanation: 'Optimizasyon için yeterli yer yok.'
|
||
}),
|
||
{
|
||
status: 400,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||
}
|
||
);
|
||
}
|
||
|
||
// Tüm yerlerin koordinatları var mı kontrol et
|
||
const missingCoords = places.filter(p => !p.lat || !p.lng);
|
||
if (missingCoords.length > 0) {
|
||
return new Response(
|
||
JSON.stringify({
|
||
error: 'Bazı yerlerin koordinatları eksik',
|
||
optimizedOrder: [],
|
||
distanceBeforeKm: 0,
|
||
distanceAfterKm: 0,
|
||
estimatedSavings: { distanceKm: 0, timeMinutes: 0 },
|
||
explanation: 'Tüm yerlerin koordinatları olmalı.'
|
||
}),
|
||
{
|
||
status: 400,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||
}
|
||
);
|
||
}
|
||
|
||
// Orijinal toplam mesafe
|
||
const originalDistance = calculateTotalDistance(places);
|
||
|
||
// Rotayı optimize et
|
||
const optimizedPlaces = optimizeRoute(places, startLocation);
|
||
|
||
// Optimize edilmiş toplam mesafe
|
||
const optimizedDistance = calculateTotalDistance(optimizedPlaces);
|
||
|
||
// Tasarruf hesapla
|
||
const distanceSaved = Math.max(0, originalDistance - optimizedDistance);
|
||
const timeSaved = Math.round(distanceSaved / 50 * 60); // 50 km/h ortalama hız varsayımı
|
||
|
||
// Sonuç oluştur
|
||
const result: OptimizationResult = {
|
||
optimizedOrder: optimizedPlaces.map((place, index) => ({
|
||
tripPlaceId: place.tripPlaceId,
|
||
placeId: place.placeId,
|
||
name: place.name,
|
||
newOrderIndex: index,
|
||
oldOrderIndex: place.order_index,
|
||
})),
|
||
distanceBeforeKm: originalDistance,
|
||
distanceAfterKm: optimizedDistance,
|
||
estimatedSavings: {
|
||
distanceKm: distanceSaved,
|
||
timeMinutes: timeSaved,
|
||
},
|
||
explanation: generateExplanation(places, optimizedPlaces),
|
||
};
|
||
|
||
return new Response(
|
||
JSON.stringify(result),
|
||
{
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||
}
|
||
);
|
||
|
||
} catch (error) {
|
||
console.error('Optimize route error:', error);
|
||
return new Response(
|
||
JSON.stringify({
|
||
error: error.message,
|
||
optimizedOrder: [],
|
||
distanceBeforeKm: 0,
|
||
distanceAfterKm: 0,
|
||
estimatedSavings: { distanceKm: 0, timeMinutes: 0 },
|
||
explanation: 'Optimizasyon sırasında bir hata oluştu.'
|
||
}),
|
||
{
|
||
status: 500,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||
}
|
||
);
|
||
}
|
||
});
|