Add graph route alternatives for external points
Build and deploy Docker image / build (push) Successful in 1m10s

This commit is contained in:
Ruslan Bakiev
2026-09-29 16:18:30 +07:00
parent 407bba8b43
commit 06519c9b15
3 changed files with 200 additions and 2 deletions
+13
View File
@@ -16,10 +16,14 @@ export type ArangoDoc = Record<string, any>
export interface RouteStage { export interface RouteStage {
fromUuid: string | null fromUuid: string | null
fromName: string | null fromName: string | null
fromCountryCode: string | null
fromNodeType: string | null
fromLat: number | null fromLat: number | null
fromLon: number | null fromLon: number | null
toUuid: string | null toUuid: string | null
toName: string | null toName: string | null
toCountryCode: string | null
toNodeType: string | null
toLat: number | null toLat: number | null
toLon: number | null toLon: number | null
distanceKm: number distanceKm: number
@@ -33,16 +37,25 @@ export interface RoutePath {
stages: RouteStage[] stages: RouteStage[]
} }
export function normalizeRouteNodeType(nodeType: unknown): string | null {
if (nodeType === 'logistics' || nodeType === null || nodeType === undefined) return 'hub'
return String(nodeType)
}
function buildStage(fromDoc: ArangoDoc | undefined, toDoc: ArangoDoc | undefined, transportType: string, edges: ArangoDoc[]): RouteStage { function buildStage(fromDoc: ArangoDoc | undefined, toDoc: ArangoDoc | undefined, transportType: string, edges: ArangoDoc[]): RouteStage {
const distance = edges.reduce((s, e) => s + (e.distance_km || 0), 0) const distance = edges.reduce((s, e) => s + (e.distance_km || 0), 0)
const time = edges.reduce((s, e) => s + (e.travel_time_seconds || 0), 0) const time = edges.reduce((s, e) => s + (e.travel_time_seconds || 0), 0)
return { return {
fromUuid: fromDoc?._key ?? null, fromUuid: fromDoc?._key ?? null,
fromName: fromDoc?.name ?? null, fromName: fromDoc?.name ?? null,
fromCountryCode: fromDoc?.country_code ?? null,
fromNodeType: normalizeRouteNodeType(fromDoc?.node_type),
fromLat: fromDoc?.latitude ?? null, fromLat: fromDoc?.latitude ?? null,
fromLon: fromDoc?.longitude ?? null, fromLon: fromDoc?.longitude ?? null,
toUuid: toDoc?._key ?? null, toUuid: toDoc?._key ?? null,
toName: toDoc?.name ?? null, toName: toDoc?.name ?? null,
toCountryCode: toDoc?.country_code ?? null,
toNodeType: normalizeRouteNodeType(toDoc?.node_type),
toLat: toDoc?.latitude ?? null, toLat: toDoc?.latitude ?? null,
toLon: toDoc?.longitude ?? null, toLon: toDoc?.longitude ?? null,
distanceKm: distance, distanceKm: distance,
+186 -1
View File
@@ -1,6 +1,6 @@
import { getDb, ensureGraph } from './db.js' import { getDb, ensureGraph } from './db.js'
import { getClusteredNodes } from './cluster.js' import { getClusteredNodes } from './cluster.js'
import { distanceKm, buildRouteFromEdges, type ArangoDoc, type RoutePath } from './helpers.js' import { distanceKm, buildRouteFromEdges, normalizeRouteNodeType, type ArangoDoc, type RoutePath, type RouteStage } from './helpers.js'
const GRAPHHOPPER_URL = process.env.GRAPHHOPPER_EXTERNAL_URL || 'https://graphhopper.optovia.ru' const GRAPHHOPPER_URL = process.env.GRAPHHOPPER_EXTERNAL_URL || 'https://graphhopper.optovia.ru'
const OPENRAILROUTING_URL = process.env.OPENRAILROUTING_EXTERNAL_URL || 'https://openrailrouting.optovia.ru' const OPENRAILROUTING_URL = process.env.OPENRAILROUTING_EXTERNAL_URL || 'https://openrailrouting.optovia.ru'
@@ -45,10 +45,14 @@ export const typeDefs = `#graphql
type RouteStage { type RouteStage {
fromUuid: String fromUuid: String
fromName: String fromName: String
fromCountryCode: String
fromNodeType: String
fromLat: Float fromLat: Float
fromLon: Float fromLon: Float
toUuid: String toUuid: String
toName: String toName: String
toCountryCode: String
toNodeType: String
toLat: Float toLat: Float
toLon: Float toLon: Float
distanceKm: Float distanceKm: Float
@@ -207,6 +211,19 @@ export const typeDefs = `#graphql
hubsForProduct(productUuid: String!, radiusKm: Float): [Node!]! hubsForProduct(productUuid: String!, radiusKm: Float): [Node!]!
offersByHub(hubUuid: String!, productUuid: String!, limit: Int): [ProductRouteOption!]! offersByHub(hubUuid: String!, productUuid: String!, limit: Int): [ProductRouteOption!]!
offerToHub(offerUuid: String!, hubUuid: String!): ProductRouteOption offerToHub(offerUuid: String!, hubUuid: String!): ProductRouteOption
routesBetweenNodes(
fromUuid: String!
fromName: String
fromCountryCode: String
fromLat: Float
fromLon: Float
toUuid: String!
toName: String
toCountryCode: String
toLat: Float
toLon: Float
limit: Int = 3
): [RoutePath!]!
nearestHubs(lat: Float!, lon: Float!, radius: Float, productUuid: String, limit: Int): [Node!]! nearestHubs(lat: Float!, lon: Float!, radius: Float, productUuid: String, limit: Int): [Node!]!
nearestOffers(lat: Float!, lon: Float!, radius: Float, productUuid: String, hubUuid: String, limit: Int): [OfferWithRoute!]! nearestOffers(lat: Float!, lon: Float!, radius: Float, productUuid: String, hubUuid: String, limit: Int): [OfferWithRoute!]!
nearestSuppliers(lat: Float!, lon: Float!, radius: Float, productUuid: String, limit: Int): [Supplier!]! nearestSuppliers(lat: Float!, lon: Float!, radius: Float, productUuid: String, limit: Int): [Supplier!]!
@@ -233,6 +250,107 @@ function mapNode(doc: ArangoDoc, includeEdges = false) {
} }
} }
interface RouteEndpointArgs {
uuid: string
name?: string
countryCode?: string
lat?: number
lon?: number
}
async function resolveGraphRouteEndpoint(endpoint: RouteEndpointArgs): Promise<ArangoDoc> {
const db = getDb()
const exactCursor = await db.query(`
LET exact = DOCUMENT(@document_id)
RETURN exact
`, { document_id: `nodes/${endpoint.uuid}` })
const exact = await exactCursor.next()
if (exact) return exact
if (endpoint.lat === undefined || endpoint.lon === undefined) {
throw new Error(`GEO node ${endpoint.uuid} does not exist and coordinates were not provided`)
}
const nearestCursor = await db.query(`
FOR node IN nodes
FILTER node.node_type == 'logistics' OR node.node_type == null
FILTER node.latitude != null AND node.longitude != null
SORT DISTANCE(node.latitude, node.longitude, @latitude, @longitude) ASC
LIMIT 1
RETURN node
`, { latitude: endpoint.lat, longitude: endpoint.lon })
const nearest = await nearestCursor.next()
if (!nearest) throw new Error('GEO graph does not contain a route hub')
return nearest
}
function routeConnectorStage(
endpoint: RouteEndpointArgs,
graphNode: ArangoDoc,
endpointIsSource: boolean,
): RouteStage | null {
if (endpoint.uuid === graphNode._key) return null
if (endpoint.lat === undefined || endpoint.lon === undefined) return null
const graphLat = graphNode.latitude as number | null
const graphLon = graphNode.longitude as number | null
if (graphLat === null || graphLon === null) return null
const connectorDistance = distanceKm(endpoint.lat, endpoint.lon, graphLat, graphLon)
const endpointData = {
uuid: endpoint.uuid,
name: endpoint.name ?? endpoint.uuid,
countryCode: endpoint.countryCode ?? null,
lat: endpoint.lat,
lon: endpoint.lon,
nodeType: 'external',
}
const graphData = {
uuid: graphNode._key as string,
name: (graphNode.name as string | undefined) ?? graphNode._key as string,
countryCode: (graphNode.country_code as string | undefined) ?? null,
lat: graphLat,
lon: graphLon,
nodeType: normalizeRouteNodeType(graphNode.node_type),
}
const from = endpointIsSource ? endpointData : graphData
const to = endpointIsSource ? graphData : endpointData
return {
fromUuid: from.uuid,
fromName: from.name,
fromCountryCode: from.countryCode,
fromNodeType: from.nodeType,
fromLat: from.lat,
fromLon: from.lon,
toUuid: to.uuid,
toName: to.name,
toCountryCode: to.countryCode,
toNodeType: to.nodeType,
toLat: to.lat,
toLon: to.lon,
distanceKm: connectorDistance,
travelTimeSeconds: Math.round((connectorDistance / 60) * 3600),
transportType: 'road',
}
}
function withRouteConnectors(
route: RoutePath,
fromEndpoint: RouteEndpointArgs,
fromNode: ArangoDoc,
toEndpoint: RouteEndpointArgs,
toNode: ArangoDoc,
): RoutePath {
const firstMile = routeConnectorStage(fromEndpoint, fromNode, true)
const lastMile = routeConnectorStage(toEndpoint, toNode, false)
const stages = [
...(firstMile ? [firstMile] : []),
...route.stages,
...(lastMile ? [lastMile] : []),
]
return {
stages,
totalDistanceKm: stages.reduce((sum, stage) => sum + stage.distanceKm, 0),
totalTimeSeconds: stages.reduce((sum, stage) => sum + stage.travelTimeSeconds, 0),
}
}
function productView(doc: ArangoDoc) { function productView(doc: ArangoDoc) {
const productUuid = doc.product_uuid ?? doc.uuid ?? null const productUuid = doc.product_uuid ?? doc.uuid ?? null
return { return {
@@ -1111,6 +1229,73 @@ export const resolvers = {
return resolveOfferToHubInternal(args.offerUuid, args.hubUuid) return resolveOfferToHubInternal(args.offerUuid, args.hubUuid)
}, },
routesBetweenNodes: async (_: unknown, args: {
fromUuid: string
fromName?: string
fromCountryCode?: string
fromLat?: number
fromLon?: number
toUuid: string
toName?: string
toCountryCode?: string
toLat?: number
toLon?: number
limit?: number
}) => {
const db = getDb()
await ensureGraph()
const limit = Math.min(Math.max(args.limit ?? 3, 1), 3)
const fromEndpoint: RouteEndpointArgs = {
uuid: args.fromUuid,
name: args.fromName,
countryCode: args.fromCountryCode,
lat: args.fromLat,
lon: args.fromLon,
}
const toEndpoint: RouteEndpointArgs = {
uuid: args.toUuid,
name: args.toName,
countryCode: args.toCountryCode,
lat: args.toLat,
lon: args.toLon,
}
const fromNode = await resolveGraphRouteEndpoint(fromEndpoint)
const toNode = await resolveGraphRouteEndpoint(toEndpoint)
const cursor = await db.query(`
FOR path IN ANY K_SHORTEST_PATHS @from_id TO @to_id
GRAPH 'optovia_graph'
OPTIONS {
weightAttribute: 'travel_time_seconds',
defaultWeight: 1
}
LIMIT @limit
RETURN path
`, {
from_id: fromNode._id,
to_id: toNode._id,
limit,
})
const paths = await cursor.all()
return paths
.map((path: ArangoDoc) => {
const nodeDocs = new Map<string, ArangoDoc>(
path.vertices.map((vertex: ArangoDoc) => [vertex._key as string, vertex]),
)
const pathEdges: [string, string, ArangoDoc][] = path.edges.map(
(edge: ArangoDoc, index: number) => [
path.vertices[index]._key as string,
path.vertices[index + 1]._key as string,
edge,
],
)
const route = buildRouteFromEdges(pathEdges, nodeDocs)
return route
? withRouteConnectors(route, fromEndpoint, fromNode, toEndpoint, toNode)
: null
})
.filter((route: RoutePath | null): route is RoutePath => route !== null)
},
nearestHubs: async (_: unknown, args: { lat: number; lon: number; radius?: number; productUuid?: string; limit?: number }) => { nearestHubs: async (_: unknown, args: { lat: number; lon: number; radius?: number; productUuid?: string; limit?: number }) => {
const db = getDb() const db = getDb()
const radius = args.radius ?? 1000 const radius = args.radius ?? 1000