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 }