Add graph route alternatives for external points
Build and deploy Docker image / build (push) Successful in 1m10s
Build and deploy Docker image / build (push) Successful in 1m10s
This commit is contained in:
+1
-1
Submodule graphql-contracts updated: 1af133dd20...99ecc3b719
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user