- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService) - Add travel orchestrator with parallel collectors (events, POI, hotels, flights) - Add 2GIS road routing with transport cost calculation (car/bus/taxi) - Add TravelMap (2GIS MapGL) and TravelWidgets components - Add useTravelChat hook for streaming travel agent responses - Add finance heatmap providers refactor - Add SearXNG settings, API proxy routes, Docker compose updates - Update Dockerfiles, config, types, and all UI pages for consistency Made-with: Cursor
267 lines
6.8 KiB
Go
267 lines
6.8 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
var (
|
|
iataCities map[string]string // lowercase city name -> IATA code
|
|
iataCitiesMu sync.RWMutex
|
|
iataLoaded bool
|
|
)
|
|
|
|
type tpCityEntry struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
CountryCode string `json:"country_code"`
|
|
Coordinates struct {
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
} `json:"coordinates"`
|
|
}
|
|
|
|
func loadIATACities(ctx context.Context, token string) error {
|
|
iataCitiesMu.Lock()
|
|
defer iataCitiesMu.Unlock()
|
|
|
|
if iataLoaded {
|
|
return nil
|
|
}
|
|
|
|
u := fmt.Sprintf("https://api.travelpayouts.com/data/ru/cities.json?token=%s", url.QueryEscape(token))
|
|
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch cities.json: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("cities.json returned %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read cities.json: %w", err)
|
|
}
|
|
|
|
var cities []tpCityEntry
|
|
if err := json.Unmarshal(body, &cities); err != nil {
|
|
return fmt.Errorf("failed to parse cities.json: %w", err)
|
|
}
|
|
|
|
iataCities = make(map[string]string, len(cities))
|
|
for _, c := range cities {
|
|
if c.Name != "" && c.Code != "" {
|
|
iataCities[strings.ToLower(c.Name)] = c.Code
|
|
}
|
|
}
|
|
|
|
iataLoaded = true
|
|
log.Printf("[travel-flights] loaded %d IATA city codes", len(iataCities))
|
|
return nil
|
|
}
|
|
|
|
func cityToIATA(city string) string {
|
|
iataCitiesMu.RLock()
|
|
defer iataCitiesMu.RUnlock()
|
|
|
|
normalized := strings.ToLower(strings.TrimSpace(city))
|
|
if code, ok := iataCities[normalized]; ok {
|
|
return code
|
|
}
|
|
|
|
aliases := map[string]string{
|
|
"питер": "LED",
|
|
"спб": "LED",
|
|
"петербург": "LED",
|
|
"мск": "MOW",
|
|
"нск": "OVB",
|
|
"новосиб": "OVB",
|
|
"нижний": "GOJ",
|
|
"екб": "SVX",
|
|
"ростов": "ROV",
|
|
"ростов-на-дону": "ROV",
|
|
"красноярск": "KJA",
|
|
"владивосток": "VVO",
|
|
"калининград": "KGD",
|
|
"сочи": "AER",
|
|
"адлер": "AER",
|
|
"симферополь": "SIP",
|
|
"крым": "SIP",
|
|
}
|
|
if code, ok := aliases[normalized]; ok {
|
|
return code
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
type tpFlightResponse struct {
|
|
Data []tpFlightData `json:"data"`
|
|
Success bool `json:"success"`
|
|
}
|
|
|
|
type tpFlightData struct {
|
|
Origin string `json:"origin"`
|
|
Destination string `json:"destination"`
|
|
OriginAirport string `json:"origin_airport"`
|
|
DestAirport string `json:"destination_airport"`
|
|
Price float64 `json:"price"`
|
|
Airline string `json:"airline"`
|
|
FlightNumber string `json:"flight_number"`
|
|
DepartureAt string `json:"departure_at"`
|
|
ReturnAt string `json:"return_at"`
|
|
Transfers int `json:"transfers"`
|
|
ReturnTransfers int `json:"return_transfers"`
|
|
Duration int `json:"duration"`
|
|
DurationTo int `json:"duration_to"`
|
|
DurationBack int `json:"duration_back"`
|
|
Link string `json:"link"`
|
|
Gate string `json:"gate"`
|
|
}
|
|
|
|
// CollectFlightsFromTP searches TravelPayouts for flights between origin and destinations.
|
|
func CollectFlightsFromTP(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief) ([]TransportOption, error) {
|
|
if cfg.TravelPayoutsToken == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
if brief.Origin == "" || len(brief.Destinations) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
if err := loadIATACities(ctx, cfg.TravelPayoutsToken); err != nil {
|
|
log.Printf("[travel-flights] failed to load IATA cities: %v", err)
|
|
}
|
|
|
|
originIATA := cityToIATA(brief.Origin)
|
|
if originIATA == "" {
|
|
log.Printf("[travel-flights] unknown IATA for origin '%s'", brief.Origin)
|
|
return nil, nil
|
|
}
|
|
|
|
var allTransport []TransportOption
|
|
|
|
passengers := brief.Travelers
|
|
if passengers < 1 {
|
|
passengers = 1
|
|
}
|
|
|
|
for _, dest := range brief.Destinations {
|
|
destIATA := cityToIATA(dest)
|
|
if destIATA == "" {
|
|
log.Printf("[travel-flights] unknown IATA for destination '%s'", dest)
|
|
continue
|
|
}
|
|
|
|
flights, err := searchTPFlights(ctx, cfg.TravelPayoutsToken, cfg.TravelPayoutsMarker, originIATA, destIATA, brief.StartDate, brief.EndDate)
|
|
if err != nil {
|
|
log.Printf("[travel-flights] search failed %s->%s: %v", originIATA, destIATA, err)
|
|
continue
|
|
}
|
|
|
|
for _, f := range flights {
|
|
allTransport = append(allTransport, TransportOption{
|
|
ID: uuid.New().String(),
|
|
Mode: "flight",
|
|
From: brief.Origin,
|
|
To: dest,
|
|
Departure: f.DepartureAt,
|
|
Arrival: "",
|
|
DurationMin: f.DurationTo,
|
|
PricePerUnit: f.Price,
|
|
Passengers: passengers,
|
|
Price: f.Price * float64(passengers),
|
|
Currency: "RUB",
|
|
Provider: f.Gate,
|
|
BookingURL: buildTPBookingURL(f.Link, cfg.TravelPayoutsMarker),
|
|
Airline: f.Airline,
|
|
FlightNum: f.FlightNumber,
|
|
Stops: f.Transfers,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(allTransport) > 10 {
|
|
allTransport = allTransport[:10]
|
|
}
|
|
|
|
return allTransport, nil
|
|
}
|
|
|
|
func searchTPFlights(ctx context.Context, token, marker, origin, destination, departDate, returnDate string) ([]tpFlightData, error) {
|
|
params := url.Values{
|
|
"origin": {origin},
|
|
"destination": {destination},
|
|
"currency": {"rub"},
|
|
"sorting": {"price"},
|
|
"limit": {"5"},
|
|
"token": {token},
|
|
}
|
|
|
|
if departDate != "" {
|
|
params.Set("departure_at", departDate)
|
|
}
|
|
if returnDate != "" && returnDate != departDate {
|
|
params.Set("return_at", returnDate)
|
|
}
|
|
|
|
u := "https://api.travelpayouts.com/aviasales/v3/prices_for_dates?" + params.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("TP flights request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("TP flights returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result tpFlightResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("TP flights decode error: %w", err)
|
|
}
|
|
|
|
return result.Data, nil
|
|
}
|
|
|
|
func buildTPBookingURL(link, marker string) string {
|
|
if link == "" {
|
|
return ""
|
|
}
|
|
base := "https://www.aviasales.ru" + link
|
|
if marker != "" {
|
|
if strings.Contains(base, "?") {
|
|
base += "&marker=" + marker
|
|
} else {
|
|
base += "?marker=" + marker
|
|
}
|
|
}
|
|
return base
|
|
}
|