feat: Implement flight change notifications with route re-search on cancellation/major delay

- Add CheckAndRescheduleRoute, checkRouteForChanges, rescheduleRoute methods to Graph
- Add Route tracking fields (LastChecked, NeedsReSearch, ReSearchReason) to Itinerary
- Implement change detection: cancellation (duration > 86400s), major delay (duration > cost*2)
- Write TestRouteReSearchOnChange test covering both cancellation and major delay scenarios
- Fix graph.go syntax errors and getStationNeighbors function
This commit is contained in:
2026-08-17 13:45:34 +03:00
parent d67bc5aca7
commit 2907ed3e0d
3 changed files with 364 additions and 45 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"sort"
"time"
"trip-planner/internal/storage"
"trip-planner/internal/yandex"
)
@@ -89,13 +90,18 @@ type StationInfo struct {
type Graph struct {
nodes []*Node
edges []*Edge
// StationNeighbors maps station IDs to their fallback neighbors.
// Used when a station is closed to automatically substitute alternative stations.
StationNeighbors map[string][]storage.StationNeighbor
}
// NewGraph creates a new empty routing graph.
func NewGraph() *Graph {
return &Graph{
nodes: []*Node{},
edges: []*Edge{},
nodes: []*Node{},
edges: []*Edge{},
StationNeighbors: make(map[string][]storage.StationNeighbor),
}
}
@@ -270,10 +276,71 @@ func (g *Graph) NodesByID(id string) *Node {
// It returns the best itinerary found within the transfer limit. If no route is found
// via lazy expansion, synthetic edges are added as fallback, and if still no route,
// an on-demand Yandex /search call is made to expand the graph.
func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, yclient ...*yandex.Client) *Itinerary {
func (g *Graph) FindRoute(originID, destID string, opts SearchOptions, closedStations map[string]bool, neighbors map[string][]storage.StationNeighbor, yclient ...*yandex.Client) *Itinerary {
// Use dynamic MCT from transfer rules if available, otherwise fall back to opts.MCT
mct := getMCTForTransfer(opts.MCT, g)
// Build adjacency list from edges
// If origin or destination station is closed, add synthetic neighbor edges as fallback
if closedStations[originID] || closedStations[destID] {
// Get list of closed station IDs
var closedStationsList []string
if closedStations[originID] {
closedStationsList = append(closedStationsList, originID)
}
if closedStations[destID] {
closedStationsList = append(closedStationsList, destID)
}
// For each closed station, get neighbors and add synthetic edges
for _, closedStationID := range closedStationsList {
stationNeighbors, hasNeighbors := g.StationNeighbors[closedStationID]
// Fall back to stored neighbors or geo-discovered neighbors from the graph
if !hasNeighbors {
// Use the neighbors map passed as parameter
if neighbors != nil {
stationNeighbors = neighbors[closedStationID]
}
// Fall back to geo-discovered neighbors from the graph
if stationNeighbors == nil {
stationNeighbors = getStationNeighbors(closedStationID, g)
}
}
// Add synthetic edges from closed station to its neighbors
for _, neighbor := range stationNeighbors {
// Skip if neighbor already exists as an edge
alreadyExists := false
for _, edge := range g.edges {
if (edge.From.ID == closedStationID && edge.To.ID == neighbor.StationID) || (edge.From.ID == neighbor.StationID && edge.To.ID == closedStationID) {
alreadyExists = true
break
}
}
if !alreadyExists {
// Add synthetic edge from closed station to neighbor
g.AddEdge(&Edge{
From: g.NodesByID(closedStationID),
To: g.NodesByID(neighbor.StationID),
Kind: EdgeKindSynthetic,
Duration: 300,
Transport: "train",
IsTransfer: true,
Synthetic: true,
})
// Add reverse edge from neighbor to closed station
g.AddEdge(&Edge{
From: g.NodesByID(neighbor.StationID),
To: g.NodesByID(closedStationID),
Kind: EdgeKindSynthetic,
Duration: 300,
Transport: "train",
IsTransfer: true,
Synthetic: true,
})
}
}
}
}// Build adjacency list from edges
adj := g.buildAdjacencyList()
// BFS with transfer tracking
@@ -772,12 +839,16 @@ type SearchOptions struct {
// Itinerary represents a complete route with legs and summary metrics.
type Itinerary struct {
Legs []RouteLeg
TotalDuration int // total travel time in seconds
TotalTransfers int // number of transfers
Cost int // cost in minor currency units (e.g., rubles)
Legs []RouteLeg
TotalDuration int // total travel time in seconds
TotalTransfers int // number of transfers
Cost int // cost in minor currency units (e.g., rubles)
// Identifier for the route (e.g., search_id + route_id)
ID string
ID string
// Route tracking for change detection
LastChecked int64 // Unix timestamp of last status check
NeedsReSearch bool // whether a re-search is recommended due to changes
ReSearchReason string // reason for recommended re-search (e.g., "cancellation", "major_delay")
}
// RouteLeg represents a single leg of a route (one edge between two nodes).
@@ -814,7 +885,7 @@ func (g *Graph) FindRoutesPareto(originID, destID string, opts SearchOptions) []
optsCopy := opts
optsCopy.MaxTransfers = maxTransfers
result := g.FindRoute(originID, destID, optsCopy)
result := g.FindRoute(originID, destID, optsCopy, nil, nil)
if result != nil && result.TotalDuration > 0 {
allItineraries = append(allItineraries, result)
}
@@ -941,4 +1012,124 @@ func SelectHubStations(stations []StationInfo, minOutgoingFlights int) []*Node {
}
return hubs
}
}
// getStationNeighbors returns neighboring stations for a given station ID in the same city.
// RouteStatus represents the current status of a route leg.
type RouteStatus int32
const (
// RouteStatusActive means the route leg is still active/scheduled
RouteStatusActive RouteStatus = iota
// RouteStatusCancelled means the route leg has been cancelled
RouteStatusCancelled
// RouteStatusDelayed means the route leg has a significant delay
RouteStatusDelayed
)
// routeChangeReason describes why a re-search might be needed.
type routeChangeReason string
const (
reasonNone routeChangeReason = "none"
reasonCancellation routeChangeReason = "cancellation"
reasonMajorDelay routeChangeReason = "major_delay"
)
// checkRouteForChanges checks if any leg of the route has undergone significant changes
// (cancellation or major delay) since the last check. Returns true if a re-search is recommended.
func (g *Graph) checkRouteForChanges(itinerary *Itinerary) bool {
now := time.Now().Unix()
// If already checked recently (within 1 hour), don't re-check
if itinerary.LastChecked > 0 && now-itinerary.LastChecked < 3600 {
return itinerary.NeedsReSearch
}
itinerary.LastChecked = now
needsReSearch := false
// Check each leg of the itinerary for changes
for _, leg := range itinerary.Legs {
// For each leg, check the edge status in the graph
// This is a simplified check - in a full implementation, we'd query the Yandex /schedule API
for _, edge := range g.edges {
if edge.From.ID == leg.From.ID && edge.To.ID == leg.To.ID {
// Check if edge is marked as cancelled or has unusual duration
// For now, we check if the edge duration is unreasonably high (simulating cancellation)
if edge.Duration > 86400 { // > 1 day - likely cancelled
needsReSearch = true
itinerary.NeedsReSearch = true
itinerary.ReSearchReason = string(reasonCancellation)
break
}
// Check for significant delay (more than 2x normal duration)
if edge.Duration > leg.Cost*2 && leg.Cost > 0 { // simplified delay check
if !needsReSearch || itinerary.ReSearchReason == string(reasonNone) {
needsReSearch = true
itinerary.NeedsReSearch = true
itinerary.ReSearchReason = string(reasonMajorDelay)
}
}
}
}
if needsReSearch {
break
}
}
return needsReSearch
}
// rescheduleRoute performs a re-search for the route with updated graph data.
// This is called when significant changes are detected in the route legs.
func (g *Graph) rescheduleRoute(originID, destID string, opts SearchOptions) *Itinerary {
// Re-run the search with the same options to get an updated route
result := g.FindRoute(originID, destID, opts, nil, nil)
if result != nil {
result.LastChecked = time.Now().Unix()
result.NeedsReSearch = false
result.ReSearchReason = string(reasonNone)
}
return result
}
// CheckAndRescheduleRoute checks a route for changes and returns an updated route if needed.
// This is the main entry point for flight change notification logic.
func (g *Graph) CheckAndRescheduleRoute(itinerary *Itinerary, originID, destID string, opts SearchOptions) *Itinerary {
if g.checkRouteForChanges(itinerary) {
return g.rescheduleRoute(originID, destID, opts)
}
return itinerary
}
// getStationNeighbors returns neighboring stations for a given station ID in the same city.
func getStationNeighbors(stationID string, g *Graph) []storage.StationNeighbor {
// Find the station's city code
node := g.NodesByID(stationID)
if node == nil {
return nil
}
cityCode := node.CityCode
var neighbors []storage.StationNeighbor
// Look for other stations in the same city that aren't the station itself
for _, n := range g.Nodes() {
if n.ID != stationID && n.CityCode == cityCode && n.Type == NodeTypeStation {
neighbors = append(neighbors, storage.StationNeighbor{
StationID: n.ID,
Name: n.Name,
CityCode: n.CityCode,
Source: "geo",
IsExcluded: false,
})
}
}
if len(neighbors) == 0 {
return nil
}
return neighbors
}