import { getDb, ensureGraph } from './db.js' import { getClusteredNodes } from './cluster.js' import { distanceKm, buildRouteFromEdges, type ArangoDoc, type RoutePath } 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' const MAX_EXPANSIONS = 20000 export const typeDefs = `#graphql type Edge { toUuid: String toName: String toLatitude: Float toLongitude: Float distanceKm: Float travelTimeSeconds: Int transportType: String } type Node { uuid: String name: String latitude: Float longitude: Float country: String countryCode: String syncedAt: String transportTypes: [String] edges: [Edge] distanceKm: Float } type NodeConnections { hub: Node railNode: Node autoEdges: [Edge] railEdges: [Edge] } type Route { distanceKm: Float geometry: JSON } type RouteStage { fromUuid: String fromName: String fromLat: Float fromLon: Float toUuid: String toName: String toLat: Float toLon: Float distanceKm: Float travelTimeSeconds: Int transportType: String } type RoutePath { totalDistanceKm: Float totalTimeSeconds: Int stages: [RouteStage] } type ProductRouteOption { sourceUuid: String sourceName: String sourceLat: Float sourceLon: Float distanceKm: Float routes: [RoutePath] } type ClusterPoint { id: String latitude: Float longitude: Float count: Int expansionZoom: Int name: String } type Product { uuid: String name: String offersCount: Int } type Supplier { uuid: String name: String latitude: Float longitude: Float distanceKm: Float } type OfferNode { uuid: String productUuid: String productName: String supplierUuid: String supplierName: String latitude: Float longitude: Float country: String countryCode: String pricePerUnit: String currency: String quantity: String unit: String distanceKm: Float } type OfferWithRoute { uuid: String productUuid: String productName: String supplierUuid: String supplierName: String latitude: Float longitude: Float country: String countryCode: String pricePerUnit: String currency: String quantity: String unit: String distanceKm: Float routes: [RoutePath] } scalar JSON type Query { node(uuid: String!): Node nodes(limit: Int, offset: Int, transportType: String, country: String, search: String, west: Float, south: Float, east: Float, north: Float): [Node!]! nodesCount(transportType: String, country: String, west: Float, south: Float, east: Float, north: Float): Int! hubCountries: [String!]! nearestNodes(lat: Float!, lon: Float!, limit: Int): [Node!]! nodeConnections(uuid: String!, limitAuto: Int, limitRail: Int): NodeConnections autoRoute(fromLat: Float!, fromLon: Float!, toLat: Float!, toLon: Float!): Route railRoute(fromLat: Float!, fromLon: Float!, toLat: Float!, toLon: Float!): Route clusteredNodes(west: Float!, south: Float!, east: Float!, north: Float!, zoom: Int!, transportType: String, nodeType: String): [ClusterPoint!]! products: [Product!]! offersByProduct(productUuid: String!): [OfferNode!]! hubsNearOffer(offerUuid: String!, limit: Int): [Node!]! suppliers: [Supplier!]! productsBySupplier(supplierUuid: String!): [Product!]! offersBySupplierProduct(supplierUuid: String!, productUuid: String!): [OfferNode!]! productsNearHub(hubUuid: String!, radiusKm: Float): [Product!]! suppliersForProduct(productUuid: String!): [Supplier!]! hubsForProduct(productUuid: String!, radiusKm: Float): [Node!]! offersByHub(hubUuid: String!, productUuid: String!, limit: Int): [ProductRouteOption!]! offerToHub(offerUuid: String!, hubUuid: String!): ProductRouteOption 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!]! routeToCoordinate(offerUuid: String!, lat: Float!, lon: Float!): ProductRouteOption hubsList(limit: Int, offset: Int, country: String, transportType: String, west: Float, south: Float, east: Float, north: Float): [Node!]! suppliersList(limit: Int, offset: Int, country: String, west: Float, south: Float, east: Float, north: Float): [Supplier!]! productsList(limit: Int, offset: Int, west: Float, south: Float, east: Float, north: Float): [Product!]! } ` function mapNode(doc: ArangoDoc, includeEdges = false) { return { uuid: doc._key, name: doc.name ?? null, latitude: doc.latitude ?? null, longitude: doc.longitude ?? null, country: doc.country ?? null, countryCode: doc.country_code ?? null, syncedAt: doc.synced_at ?? null, transportTypes: doc.transport_types || [], edges: includeEdges ? (doc.edges || []) : [], distanceKm: doc.distance_km ?? null, } } function mapOffer(doc: ArangoDoc) { return { uuid: doc._key, productUuid: doc.product_uuid ?? null, productName: doc.product_name ?? null, supplierUuid: doc.supplier_uuid ?? null, supplierName: doc.supplier_name ?? null, latitude: doc.latitude ?? null, longitude: doc.longitude ?? null, country: doc.country ?? null, countryCode: doc.country_code ?? null, pricePerUnit: doc.price_per_unit ?? null, currency: doc.currency ?? null, quantity: doc.quantity ?? null, unit: doc.unit ?? null, distanceKm: doc.distance_km ?? null, } } function mapEdge(doc: ArangoDoc) { return { toUuid: doc.to_uuid ?? doc._key ?? null, toName: doc.to_name ?? doc.name ?? null, toLatitude: doc.to_latitude ?? doc.latitude ?? null, toLongitude: doc.to_longitude ?? doc.longitude ?? null, distanceKm: doc.distance_km ?? null, travelTimeSeconds: doc.travel_time_seconds ?? null, transportType: doc.transport_type ?? null, } } function boundsFilter(args: { west?: number | null; south?: number | null; east?: number | null; north?: number | null }, varName = 'node') { if (args.west == null || args.south == null || args.east == null || args.north == null) return { filter: '', vars: {} } return { filter: ` FILTER ${varName}.latitude != null AND ${varName}.longitude != null FILTER ${varName}.latitude >= @south AND ${varName}.latitude <= @north FILTER ${varName}.longitude >= @west AND ${varName}.longitude <= @east `, vars: { west: args.west, south: args.south, east: args.east, north: args.north }, } } // Phase-based routing helpers type Phase = 'end_auto' | 'end_auto_done' | 'rail' | 'start_auto_done' | 'offer' function allowedNextPhase(current: Phase, transportType: string): Phase | null { if (current === 'end_auto') { if (transportType === 'offer') return 'offer' if (transportType === 'auto') return 'end_auto_done' if (transportType === 'rail') return 'rail' return null } if (current === 'end_auto_done') { if (transportType === 'offer') return 'offer' if (transportType === 'rail') return 'rail' return null } if (current === 'rail') { if (transportType === 'offer') return 'offer' if (transportType === 'rail') return 'rail' if (transportType === 'auto') return 'start_auto_done' return null } if (current === 'start_auto_done') { if (transportType === 'offer') return 'offer' return null } return null } function phaseTypes(phase: Phase): string[] { if (phase === 'end_auto') return ['auto', 'rail', 'offer'] if (phase === 'end_auto_done') return ['rail', 'offer'] if (phase === 'rail') return ['rail', 'auto', 'offer'] if (phase === 'start_auto_done') return ['offer'] return ['offer'] } async function fetchNeighbors(nodeKey: string, phase: Phase): Promise { const db = getDb() const types = phaseTypes(phase) const cursor = await db.query(` FOR edge IN edges FILTER edge.transport_type IN @types FILTER edge._from == @node_id OR edge._to == @node_id LET neighbor_id = edge._from == @node_id ? edge._to : edge._from LET neighbor = DOCUMENT(neighbor_id) FILTER neighbor != null RETURN { neighbor_key: neighbor._key, neighbor_doc: neighbor, from_id: edge._from, to_id: edge._to, transport_type: edge.transport_type, distance_km: edge.distance_km, travel_time_seconds: edge.travel_time_seconds } `, { node_id: `nodes/${nodeKey}`, types }) return cursor.all() } async function resolveOfferToHubInternal(offerUuid: string, hubUuid: string): Promise { const db = getDb() await ensureGraph() const nodesCol = db.collection('nodes') const [offer, hub] = await Promise.all([nodesCol.document(offerUuid).catch(() => null), nodesCol.document(hubUuid).catch(() => null)]) if (!offer || !hub) return null const hubLat = hub.latitude const hubLon = hub.longitude const offerLat = offer.latitude const offerLon = offer.longitude // Priority queue: [cost, seq, nodeKey, phase] const queue: [number, number, string, Phase][] = [[0, 0, hubUuid, 'end_auto']] let counter = 0 const visited = new Map() const predecessors = new Map() // stateKey -> [prevStateKey, edge] const nodeDocs = new Map([[hubUuid, hub], [offerUuid, offer]]) let expansions = 0 while (queue.length > 0 && expansions < MAX_EXPANSIONS) { queue.sort((a, b) => a[0] - b[0]) const [cost, , nodeKey, phase] = queue.shift()! const stateKey = `${nodeKey}:${phase}` if (visited.has(stateKey) && cost > visited.get(stateKey)!) continue if (nodeKey === offerUuid) { const pathEdges: [string, string, ArangoDoc][] = [] let curState = stateKey let curKey = nodeKey while (predecessors.has(curState)) { const [prevState, edgeInfo] = predecessors.get(curState)! const prevKey = prevState.split(':')[0] pathEdges.push([curKey, prevKey, edgeInfo]) curState = prevState curKey = prevKey } const route = buildRouteFromEdges(pathEdges, nodeDocs) let dm: number | null = null if (offerLat != null && offerLon != null && hubLat != null && hubLon != null) { dm = distanceKm(offerLat, offerLon, hubLat, hubLon) } return { sourceUuid: offerUuid, sourceName: offer.name || offer.product_name, sourceLat: offerLat, sourceLon: offerLon, distanceKm: dm, routes: route ? [route] : [], } } const neighbors = await fetchNeighbors(nodeKey, phase) expansions++ for (const neighbor of neighbors) { const transportType = neighbor.transport_type as string const nextPhase = allowedNextPhase(phase, transportType) if (!nextPhase) continue const neighborKey = neighbor.neighbor_key as string nodeDocs.set(neighborKey, neighbor.neighbor_doc) const travelTime = neighbor.travel_time_seconds as number | null const edgeDist = neighbor.distance_km as number | null const stepCost = travelTime ?? (edgeDist || 0) const newCost = cost + stepCost const nStateKey = `${neighborKey}:${nextPhase}` if (visited.has(nStateKey) && newCost >= visited.get(nStateKey)!) continue visited.set(nStateKey, newCost) counter++ queue.push([newCost, counter, neighborKey, nextPhase]) predecessors.set(nStateKey, [stateKey, neighbor]) } } return null } export const resolvers = { JSON: { __serialize: (value: unknown) => value, __parseValue: (value: unknown) => value, __parseLiteral: (ast: { value: string }) => ast.value, }, Product: { offersCount: async (parent: { uuid: string }) => { const db = getDb() try { const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' FILTER node.product_uuid == @product_uuid COLLECT WITH COUNT INTO length RETURN length `, { product_uuid: parent.uuid }) const result = await cursor.next() return result ?? 0 } catch { return 0 } }, }, Query: { node: async (_: unknown, args: { uuid: string }) => { const db = getDb() const nodesCol = db.collection('nodes') const node = await nodesCol.document(args.uuid).catch(() => null) if (!node) return null const cursor = await db.query(` FOR edge IN edges FILTER edge._from == @from_id LET to_node = DOCUMENT(edge._to) RETURN { to_uuid: to_node._key, to_name: to_node.name, to_latitude: to_node.latitude, to_longitude: to_node.longitude, distance_km: edge.distance_km, travel_time_seconds: edge.travel_time_seconds, transport_type: edge.transport_type } `, { from_id: `nodes/${args.uuid}` }) const edges = await cursor.all() return { ...mapNode(node, true), edges: edges.map(mapEdge) } }, nodes: async (_: unknown, args: { limit?: number; offset?: number; transportType?: string; country?: string; search?: string; west?: number; south?: number; east?: number; north?: number }) => { const db = getDb() const bounds = boundsFilter(args) const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'logistics' OR node.node_type == null LET types = node.transport_types != null ? node.transport_types : [] FILTER @transport_type == null OR @transport_type IN types FILTER @country == null OR node.country == @country FILTER @search == null OR CONTAINS(LOWER(node.name), LOWER(@search)) OR CONTAINS(LOWER(node.country), LOWER(@search)) ${bounds.filter} SORT node.name ASC LIMIT @offset, @limit RETURN node `, { transport_type: args.transportType ?? null, country: args.country ?? null, search: args.search ?? null, offset: args.offset ?? 0, limit: args.limit ?? 1000000, ...bounds.vars, }) const nodes = await cursor.all() return nodes.map((n: ArangoDoc) => mapNode(n)) }, nodesCount: async (_: unknown, args: { transportType?: string; country?: string; west?: number; south?: number; east?: number; north?: number }) => { const db = getDb() const bounds = boundsFilter(args) const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'logistics' OR node.node_type == null LET types = node.transport_types != null ? node.transport_types : [] FILTER @transport_type == null OR @transport_type IN types FILTER @country == null OR node.country == @country ${bounds.filter} COLLECT WITH COUNT INTO length RETURN length `, { transport_type: args.transportType ?? null, country: args.country ?? null, ...bounds.vars }) return (await cursor.next()) ?? 0 }, hubCountries: async () => { const db = getDb() const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'logistics' OR node.node_type == null FILTER node.country != null COLLECT country = node.country SORT country ASC RETURN country `) return cursor.all() }, nearestNodes: async (_: unknown, args: { lat: number; lon: number; limit?: number }) => { const db = getDb() const cursor = 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 LET dist = DISTANCE(node.latitude, node.longitude, @lat, @lon) / 1000 SORT dist ASC LIMIT @limit RETURN MERGE(node, {distance_km: dist}) `, { lat: args.lat, lon: args.lon, limit: args.limit ?? 5 }) const nodes = await cursor.all() return nodes.map((n: ArangoDoc) => mapNode(n)) }, nodeConnections: async (_: unknown, args: { uuid: string; limitAuto?: number; limitRail?: number }) => { const db = getDb() const nodesCol = db.collection('nodes') const hub = await nodesCol.document(args.uuid).catch(() => null) if (!hub) return null const cursor = await db.query(` LET auto_edges = ( FOR edge IN edges FILTER edge._from == @from_id AND edge.transport_type == "auto" LET to_node = DOCUMENT(edge._to) FILTER to_node != null SORT edge.distance_km ASC LIMIT @limit_auto RETURN { to_uuid: to_node._key, to_name: to_node.name, to_latitude: to_node.latitude, to_longitude: to_node.longitude, distance_km: edge.distance_km, travel_time_seconds: edge.travel_time_seconds, transport_type: edge.transport_type } ) LET hub_has_rail = @hub_has_rail LET rail_node = hub_has_rail ? DOCUMENT(@from_id) : FIRST( FOR node IN nodes FILTER node.latitude != null AND node.longitude != null FILTER 'rail' IN node.transport_types SORT DISTANCE(@hub_lat, @hub_lon, node.latitude, node.longitude) LIMIT 1 RETURN node ) LET rail_edges = rail_node == null ? [] : ( FOR edge IN edges FILTER edge._from == CONCAT("nodes/", rail_node._key) AND edge.transport_type == "rail" LET to_node = DOCUMENT(edge._to) FILTER to_node != null SORT edge.distance_km ASC LIMIT @limit_rail RETURN { to_uuid: to_node._key, to_name: to_node.name, to_latitude: to_node.latitude, to_longitude: to_node.longitude, distance_km: edge.distance_km, travel_time_seconds: edge.travel_time_seconds, transport_type: edge.transport_type } ) RETURN { hub: DOCUMENT(@from_id), rail_node, auto_edges, rail_edges } `, { from_id: `nodes/${args.uuid}`, hub_lat: hub.latitude, hub_lon: hub.longitude, hub_has_rail: (hub.transport_types || []).includes('rail'), limit_auto: args.limitAuto ?? 12, limit_rail: args.limitRail ?? 12, }) const result = await cursor.next() if (!result) return null return { hub: result.hub ? mapNode(result.hub) : null, railNode: result.rail_node ? mapNode(result.rail_node) : null, autoEdges: (result.auto_edges || []).map(mapEdge), railEdges: (result.rail_edges || []).map(mapEdge), } }, autoRoute: async (_: unknown, args: { fromLat: number; fromLon: number; toLat: number; toLon: number }) => { const url = new URL('/route', GRAPHHOPPER_URL) url.searchParams.append('point', `${args.fromLat},${args.fromLon}`) url.searchParams.append('point', `${args.toLat},${args.toLon}`) url.searchParams.append('profile', 'car') url.searchParams.append('instructions', 'false') url.searchParams.append('calc_points', 'true') url.searchParams.append('points_encoded', 'false') try { const res = await fetch(url.toString(), { signal: AbortSignal.timeout(30000) }) const data = await res.json() as ArangoDoc if (data.paths?.length > 0) { const path = data.paths[0] return { distanceKm: Math.round((path.distance || 0) / 10) / 100, geometry: path.points?.coordinates || [] } } } catch (e) { console.error('GraphHopper request failed:', e) } return null }, railRoute: async (_: unknown, args: { fromLat: number; fromLon: number; toLat: number; toLon: number }) => { const url = new URL('/route', OPENRAILROUTING_URL) url.searchParams.append('point', `${args.fromLat},${args.fromLon}`) url.searchParams.append('point', `${args.toLat},${args.toLon}`) url.searchParams.append('profile', 'all_tracks') url.searchParams.append('calc_points', 'true') url.searchParams.append('points_encoded', 'false') try { const res = await fetch(url.toString(), { signal: AbortSignal.timeout(60000) }) const data = await res.json() as ArangoDoc if (data.paths?.length > 0) { const path = data.paths[0] return { distanceKm: Math.round((path.distance || 0) / 10) / 100, geometry: path.points?.coordinates || [] } } } catch (e) { console.error('OpenRailRouting request failed:', e) } return null }, clusteredNodes: async (_: unknown, args: { west: number; south: number; east: number; north: number; zoom: number; transportType?: string; nodeType?: string }) => { const points = await getClusteredNodes(args.west, args.south, args.east, args.north, args.zoom, args.transportType, args.nodeType) return points.map((p: ArangoDoc) => ({ id: p.id, latitude: p.latitude, longitude: p.longitude, count: p.count, expansionZoom: p.expansion_zoom ?? p.expansionZoom ?? null, name: p.name ?? null, })) }, products: async () => { const db = getDb() const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' FILTER node.product_uuid != null COLLECT product_uuid = node.product_uuid INTO offers LET first_offer = FIRST(offers).node RETURN { uuid: product_uuid, name: first_offer.product_name } `) return cursor.all() }, offersByProduct: async (_: unknown, args: { productUuid: string }) => { const db = getDb() const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' FILTER node.product_uuid == @product_uuid RETURN node `, { product_uuid: args.productUuid }) const nodes = await cursor.all() return nodes.map(mapOffer) }, hubsNearOffer: async (_: unknown, args: { offerUuid: string; limit?: number }) => { const db = getDb() const nodesCol = db.collection('nodes') const offer = await nodesCol.document(args.offerUuid).catch(() => null) if (!offer || offer.latitude == null || offer.longitude == null) return [] const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'logistics' OR node.node_type == null FILTER node.product_uuid == null FILTER node.latitude != null AND node.longitude != null LET dist = DISTANCE(node.latitude, node.longitude, @lat, @lon) / 1000 SORT dist ASC LIMIT @limit RETURN MERGE(node, {distance_km: dist}) `, { lat: offer.latitude, lon: offer.longitude, limit: args.limit ?? 12 }) const nodes = await cursor.all() return nodes.map((n: ArangoDoc) => mapNode(n)) }, suppliers: async () => { const db = getDb() const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' FILTER node.supplier_uuid != null COLLECT supplier_uuid = node.supplier_uuid RETURN { uuid: supplier_uuid } `) return cursor.all() }, productsBySupplier: async (_: unknown, args: { supplierUuid: string }) => { const db = getDb() const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' FILTER node.supplier_uuid == @supplier_uuid FILTER node.product_uuid != null COLLECT product_uuid = node.product_uuid INTO offers LET first_offer = FIRST(offers).node RETURN { uuid: product_uuid, name: first_offer.product_name } `, { supplier_uuid: args.supplierUuid }) return cursor.all() }, offersBySupplierProduct: async (_: unknown, args: { supplierUuid: string; productUuid: string }) => { const db = getDb() const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' FILTER node.supplier_uuid == @supplier_uuid FILTER node.product_uuid == @product_uuid RETURN node `, { supplier_uuid: args.supplierUuid, product_uuid: args.productUuid }) const nodes = await cursor.all() return nodes.map(mapOffer) }, productsNearHub: async (_: unknown, args: { hubUuid: string; radiusKm?: number }) => { const db = getDb() const nodesCol = db.collection('nodes') const hub = await nodesCol.document(args.hubUuid).catch(() => null) if (!hub || hub.latitude == null || hub.longitude == null) return [] const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' FILTER node.product_uuid != null FILTER node.latitude != null AND node.longitude != null LET dist = DISTANCE(node.latitude, node.longitude, @lat, @lon) / 1000 FILTER dist <= @radius_km COLLECT product_uuid = node.product_uuid INTO offers LET first_offer = FIRST(offers).node RETURN { uuid: product_uuid, name: first_offer.product_name } `, { lat: hub.latitude, lon: hub.longitude, radius_km: args.radiusKm ?? 500 }) return cursor.all() }, suppliersForProduct: async (_: unknown, args: { productUuid: string }) => { const db = getDb() const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' FILTER node.product_uuid == @product_uuid FILTER node.supplier_uuid != null COLLECT supplier_uuid = node.supplier_uuid RETURN { uuid: supplier_uuid } `, { product_uuid: args.productUuid }) return cursor.all() }, hubsForProduct: async (_: unknown, args: { productUuid: string; radiusKm?: number }) => { const db = getDb() const cursor = await db.query(` FOR offer IN nodes FILTER offer.node_type == 'offer' FILTER offer.product_uuid == @product_uuid FILTER offer.latitude != null AND offer.longitude != null FOR hub IN nodes FILTER hub.node_type == 'logistics' OR hub.node_type == null FILTER hub.latitude != null AND hub.longitude != null LET dist = DISTANCE(offer.latitude, offer.longitude, hub.latitude, hub.longitude) / 1000 FILTER dist <= @radius_km COLLECT hub_uuid = hub._key, hub_name = hub.name, hub_lat = hub.latitude, hub_lon = hub.longitude, hub_country = hub.country, hub_country_code = hub.country_code, hub_transport = hub.transport_types RETURN { uuid: hub_uuid, name: hub_name, latitude: hub_lat, longitude: hub_lon, country: hub_country, country_code: hub_country_code, transport_types: hub_transport } `, { product_uuid: args.productUuid, radius_km: args.radiusKm ?? 500 }) const hubs = await cursor.all() return hubs.map((h: ArangoDoc) => ({ ...mapNode(h), uuid: h.uuid })) }, offersByHub: async (_: unknown, args: { hubUuid: string; productUuid: string; limit?: number }) => { const db = getDb() await ensureGraph() const nodesCol = db.collection('nodes') const hub = await nodesCol.document(args.hubUuid).catch(() => null) if (!hub || hub.latitude == null || hub.longitude == null) return [] const hubLat = hub.latitude as number const hubLon = hub.longitude as number const limit = args.limit ?? 10 // Priority queue: [cost, seq, nodeKey, phase] const queue: [number, number, string, Phase][] = [[0, 0, args.hubUuid, 'end_auto']] let counter = 0 const visited = new Map() const predecessors = new Map() const nodeDocs = new Map([[args.hubUuid, hub]]) const foundRoutes: ArangoDoc[] = [] let expansions = 0 while (queue.length > 0 && foundRoutes.length < limit && expansions < MAX_EXPANSIONS) { queue.sort((a, b) => a[0] - b[0]) const [cost, , nodeKey, phase] = queue.shift()! const stateKey = `${nodeKey}:${phase}` if (visited.has(stateKey) && cost > visited.get(stateKey)!) continue const nodeDoc = nodeDocs.get(nodeKey) if (nodeDoc && nodeDoc.product_uuid === args.productUuid) { const pathEdges: [string, string, ArangoDoc][] = [] let curState = stateKey let curKey = nodeKey while (predecessors.has(curState)) { const [prevState, edgeInfo] = predecessors.get(curState)! const prevKey = prevState.split(':')[0] pathEdges.push([curKey, prevKey, edgeInfo]) curState = prevState curKey = prevKey } const route = buildRouteFromEdges(pathEdges, nodeDocs) let dm: number | null = null const srcLat = nodeDoc.latitude as number | null const srcLon = nodeDoc.longitude as number | null if (srcLat != null && srcLon != null) dm = distanceKm(srcLat, srcLon, hubLat, hubLon) foundRoutes.push({ sourceUuid: nodeKey, sourceName: nodeDoc.name || nodeDoc.product_name, sourceLat: srcLat, sourceLon: srcLon, distanceKm: dm, routes: route ? [route] : [], }) continue } const neighbors = await fetchNeighbors(nodeKey, phase) expansions++ for (const neighbor of neighbors) { const transportType = neighbor.transport_type as string const nextPhase = allowedNextPhase(phase, transportType) if (!nextPhase) continue const neighborKey = neighbor.neighbor_key as string nodeDocs.set(neighborKey, neighbor.neighbor_doc) const travelTime = neighbor.travel_time_seconds as number | null const edgeDist = neighbor.distance_km as number | null const stepCost = travelTime ?? (edgeDist || 0) const newCost = cost + stepCost const nStateKey = `${neighborKey}:${nextPhase}` if (visited.has(nStateKey) && newCost >= visited.get(nStateKey)!) continue visited.set(nStateKey, newCost) counter++ queue.push([newCost, counter, neighborKey, nextPhase]) predecessors.set(nStateKey, [stateKey, neighbor]) } } return foundRoutes }, offerToHub: async (_: unknown, args: { offerUuid: string; hubUuid: string }) => { return resolveOfferToHubInternal(args.offerUuid, args.hubUuid) }, nearestHubs: async (_: unknown, args: { lat: number; lon: number; radius?: number; productUuid?: string; limit?: number }) => { const db = getDb() const radius = args.radius ?? 1000 const limit = args.limit ?? 12 let aql: string let bindVars: Record if (args.productUuid) { aql = ` FOR offer IN nodes FILTER offer.node_type == 'offer' FILTER offer.product_uuid == @product_uuid FILTER offer.latitude != null AND offer.longitude != null LET dist_to_offer = DISTANCE(offer.latitude, offer.longitude, @lat, @lon) / 1000 FILTER dist_to_offer <= @radius FOR hub IN nodes FILTER hub.node_type == 'logistics' OR hub.node_type == null FILTER hub.product_uuid == null FILTER hub.latitude != null AND hub.longitude != null LET dist_to_hub = DISTANCE(hub.latitude, hub.longitude, @lat, @lon) / 1000 FILTER dist_to_hub <= @radius COLLECT hub_uuid = hub._key INTO hub_group LET first_hub = FIRST(hub_group)[0].hub LET hub_dist = DISTANCE(first_hub.latitude, first_hub.longitude, @lat, @lon) / 1000 SORT hub_dist ASC LIMIT @limit RETURN MERGE(first_hub, {_key: hub_uuid, distance_km: hub_dist}) ` bindVars = { lat: args.lat, lon: args.lon, radius, product_uuid: args.productUuid, limit } } else { aql = ` FOR hub IN nodes FILTER hub.node_type == 'logistics' OR hub.node_type == null FILTER hub.product_uuid == null FILTER hub.latitude != null AND hub.longitude != null LET dist = DISTANCE(hub.latitude, hub.longitude, @lat, @lon) / 1000 FILTER dist <= @radius SORT dist ASC LIMIT @limit RETURN MERGE(hub, {distance_km: dist}) ` bindVars = { lat: args.lat, lon: args.lon, radius, limit } } const cursor = await db.query(aql, bindVars) const hubs = await cursor.all() return hubs.map((n: ArangoDoc) => mapNode(n)) }, nearestOffers: async (_: unknown, args: { lat: number; lon: number; radius?: number; productUuid?: string; hubUuid?: string; limit?: number }) => { const db = getDb() await ensureGraph() const radius = args.radius ?? 500 const limit = args.limit ?? 50 let aql = ` FOR offer IN nodes FILTER offer.node_type == 'offer' FILTER offer.product_uuid != null FILTER offer.latitude != null AND offer.longitude != null ` if (args.productUuid) aql += ` FILTER offer.product_uuid == @product_uuid\n` aql += ` LET dist = DISTANCE(offer.latitude, offer.longitude, @lat, @lon) / 1000 FILTER dist <= @radius SORT dist ASC LIMIT @limit RETURN MERGE(offer, {distance_km: dist}) ` const bindVars: Record = { lat: args.lat, lon: args.lon, radius, limit } if (args.productUuid) bindVars.product_uuid = args.productUuid const cursor = await db.query(aql, bindVars) const offerNodes = await cursor.all() const offers = [] for (const node of offerNodes) { let routes: RoutePath[] = [] if (args.hubUuid) { const routeResult = await resolveOfferToHubInternal(node._key, args.hubUuid) if (routeResult?.routes) routes = routeResult.routes as RoutePath[] } offers.push({ ...mapOffer(node), routes }) } return offers }, nearestSuppliers: async (_: unknown, args: { lat: number; lon: number; radius?: number; productUuid?: string; limit?: number }) => { const db = getDb() const radius = args.radius ?? 1000 const limit = args.limit ?? 12 let aql = ` FOR offer IN nodes FILTER offer.node_type == 'offer' FILTER offer.supplier_uuid != null FILTER offer.latitude != null AND offer.longitude != null ` if (args.productUuid) aql += ` FILTER offer.product_uuid == @product_uuid\n` aql += ` LET dist = DISTANCE(offer.latitude, offer.longitude, @lat, @lon) / 1000 FILTER dist <= @radius COLLECT supplier_uuid = offer.supplier_uuid INTO offers LET first_offer = FIRST(offers).offer LET supplier_dist = DISTANCE(first_offer.latitude, first_offer.longitude, @lat, @lon) / 1000 LET supplier_node = FIRST( FOR s IN nodes FILTER s._key == supplier_uuid FILTER s.node_type == 'supplier' RETURN s ) SORT supplier_dist ASC LIMIT @limit RETURN { uuid: supplier_uuid, name: supplier_node != null ? supplier_node.name : first_offer.supplier_name, latitude: supplier_node != null ? supplier_node.latitude : first_offer.latitude, longitude: supplier_node != null ? supplier_node.longitude : first_offer.longitude, distanceKm: supplier_dist } ` const bindVars: Record = { lat: args.lat, lon: args.lon, radius, limit } if (args.productUuid) bindVars.product_uuid = args.productUuid const cursor = await db.query(aql, bindVars) return cursor.all() }, routeToCoordinate: async (_: unknown, args: { offerUuid: string; lat: number; lon: number }) => { const db = getDb() const cursor = await db.query(` FOR hub IN nodes FILTER hub.node_type == 'logistics' OR hub.node_type == null FILTER hub.product_uuid == null FILTER hub.latitude != null AND hub.longitude != null LET dist = DISTANCE(hub.latitude, hub.longitude, @lat, @lon) / 1000 SORT dist ASC LIMIT 1 RETURN hub `, { lat: args.lat, lon: args.lon }) const hubs = await cursor.all() if (!hubs.length) return null return resolveOfferToHubInternal(args.offerUuid, hubs[0]._key) }, hubsList: async (_: unknown, args: { limit?: number; offset?: number; country?: string; transportType?: string; west?: number; south?: number; east?: number; north?: number }) => { const db = getDb() const bounds = boundsFilter(args) const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'logistics' OR node.node_type == null FILTER node.product_uuid == null LET types = node.transport_types != null ? node.transport_types : [] FILTER @transport_type == null OR @transport_type IN types FILTER @country == null OR node.country == @country ${bounds.filter} SORT node.name ASC LIMIT @offset, @limit RETURN node `, { transport_type: args.transportType ?? null, country: args.country ?? null, offset: args.offset ?? 0, limit: args.limit ?? 50, ...bounds.vars }) const nodes = await cursor.all() return nodes.map((n: ArangoDoc) => mapNode(n)) }, suppliersList: async (_: unknown, args: { limit?: number; offset?: number; country?: string; west?: number; south?: number; east?: number; north?: number }) => { const db = getDb() const bounds = boundsFilter(args) const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'supplier' FILTER @country == null OR node.country == @country ${bounds.filter} SORT node.name ASC LIMIT @offset, @limit RETURN node `, { country: args.country ?? null, offset: args.offset ?? 0, limit: args.limit ?? 50, ...bounds.vars }) const nodes = await cursor.all() return nodes.map((n: ArangoDoc) => ({ uuid: n._key, name: n.name ?? null, latitude: n.latitude ?? null, longitude: n.longitude ?? null, distanceKm: null, })) }, productsList: async (_: unknown, args: { limit?: number; offset?: number; west?: number; south?: number; east?: number; north?: number }) => { const db = getDb() const bounds = boundsFilter(args) const cursor = await db.query(` FOR node IN nodes FILTER node.node_type == 'offer' FILTER node.product_uuid != null ${bounds.filter} COLLECT product_uuid = node.product_uuid INTO offers LET first_offer = FIRST(offers).node SORT first_offer.product_name ASC LIMIT @offset, @limit RETURN { uuid: product_uuid, name: first_offer.product_name } `, { offset: args.offset ?? 0, limit: args.limit ?? 50, ...bounds.vars }) return cursor.all() }, }, }