From 06519c9b150f0bf738273f6c553230b194c79f6f Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:18:30 +0700 Subject: [PATCH] Add graph route alternatives for external points --- graphql-contracts | 2 +- src/helpers.ts | 13 ++++ src/schema.ts | 187 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 200 insertions(+), 2 deletions(-) diff --git a/graphql-contracts b/graphql-contracts index 1af133d..99ecc3b 160000 --- a/graphql-contracts +++ b/graphql-contracts @@ -1 +1 @@ -Subproject commit 1af133dd20a65cc7bae89023f3adbc6e7108c0a1 +Subproject commit 99ecc3b719e4a4192d18e4f7782a6fc5df6bc500 diff --git a/src/helpers.ts b/src/helpers.ts index b51cfb2..25377fb 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -16,10 +16,14 @@ export type ArangoDoc = Record export interface RouteStage { fromUuid: string | null fromName: string | null + fromCountryCode: string | null + fromNodeType: string | null fromLat: number | null fromLon: number | null toUuid: string | null toName: string | null + toCountryCode: string | null + toNodeType: string | null toLat: number | null toLon: number | null distanceKm: number @@ -33,16 +37,25 @@ export interface RoutePath { 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 { 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) return { fromUuid: fromDoc?._key ?? null, fromName: fromDoc?.name ?? null, + fromCountryCode: fromDoc?.country_code ?? null, + fromNodeType: normalizeRouteNodeType(fromDoc?.node_type), fromLat: fromDoc?.latitude ?? null, fromLon: fromDoc?.longitude ?? null, toUuid: toDoc?._key ?? null, toName: toDoc?.name ?? null, + toCountryCode: toDoc?.country_code ?? null, + toNodeType: normalizeRouteNodeType(toDoc?.node_type), toLat: toDoc?.latitude ?? null, toLon: toDoc?.longitude ?? null, distanceKm: distance, diff --git a/src/schema.ts b/src/schema.ts index e74ff12..90d5c4e 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,6 +1,6 @@ import { getDb, ensureGraph } from './db.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 OPENRAILROUTING_URL = process.env.OPENRAILROUTING_EXTERNAL_URL || 'https://openrailrouting.optovia.ru' @@ -45,10 +45,14 @@ export const typeDefs = `#graphql type RouteStage { fromUuid: String fromName: String + fromCountryCode: String + fromNodeType: String fromLat: Float fromLon: Float toUuid: String toName: String + toCountryCode: String + toNodeType: String toLat: Float toLon: Float distanceKm: Float @@ -207,6 +211,19 @@ export const typeDefs = `#graphql hubsForProduct(productUuid: String!, radiusKm: Float): [Node!]! offersByHub(hubUuid: String!, productUuid: String!, limit: Int): [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!]! 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!]! @@ -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 { + 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) { const productUuid = doc.product_uuid ?? doc.uuid ?? null return { @@ -1111,6 +1229,73 @@ export const resolvers = { 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( + 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 }) => { const db = getDb() const radius = args.radius ?? 1000