1048 lines
39 KiB
TypeScript
1048 lines
39 KiB
TypeScript
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<ArangoDoc[]> {
|
|
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<ArangoDoc | null> {
|
|
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<string, number>()
|
|
const predecessors = new Map<string, [string, ArangoDoc]>() // stateKey -> [prevStateKey, edge]
|
|
const nodeDocs = new Map<string, ArangoDoc>([[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<string, number>()
|
|
const predecessors = new Map<string, [string, ArangoDoc]>()
|
|
const nodeDocs = new Map<string, ArangoDoc>([[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<string, unknown>
|
|
|
|
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<string, unknown> = { 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<string, unknown> = { 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()
|
|
},
|
|
},
|
|
}
|