From 4ec650663301d0c12ef405d38ebba0395b94fbec Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:28:16 +0700 Subject: [PATCH] Unify graph routing analyzer --- geo_app/schema.py | 702 +++++++++++++++++++++++----------------------- 1 file changed, 356 insertions(+), 346 deletions(-) diff --git a/geo_app/schema.py b/geo_app/schema.py index 96ba806..47aafd6 100644 --- a/geo_app/schema.py +++ b/geo_app/schema.py @@ -933,7 +933,7 @@ class Query(graphene.ObjectType): return [] def resolve_hubs_near_offer(self, info, offer_uuid, limit=12): - """Get nearest hubs to an offer location.""" + """Get nearest hubs to an offer location (graph-based).""" db = get_db() nodes_col = db.collection('nodes') offer = nodes_col.get(offer_uuid) @@ -947,36 +947,13 @@ class Query(graphene.ObjectType): logger.info("Offer %s has no coordinates", offer_uuid) return [] - aql = """ - 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}) - """ - try: - cursor = db.aql.execute(aql, bind_vars={'lat': lat, 'lon': lon, 'limit': limit}) - hubs = [] - for node in cursor: - hubs.append(NodeType( - uuid=node['_key'], - name=node.get('name'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - country=node.get('country'), - country_code=node.get('country_code'), - synced_at=node.get('synced_at'), - transport_types=node.get('transport_types') or [], - edges=[], - )) - logger.info("Found %d hubs near offer %s", len(hubs), offer_uuid) - return hubs - except Exception as e: - logger.error("Error getting hubs near offer: %s", e) - return [] + return self.resolve_nearest_hubs( + info, + lat=lat, + lon=lon, + source_uuid=offer_uuid, + limit=limit, + ) def resolve_suppliers(self, info): """Get unique suppliers from all offers.""" @@ -1059,7 +1036,7 @@ class Query(graphene.ObjectType): return [] def resolve_products_near_hub(self, info, hub_uuid, radius_km=500): - """Get products available near a hub (within radius).""" + """Get products available via graph routes from a hub.""" db = get_db() nodes_col = db.collection('nodes') hub = nodes_col.get(hub_uuid) @@ -1067,34 +1044,26 @@ class Query(graphene.ObjectType): logger.info("Hub %s not found", hub_uuid) return [] - lat = hub.get('latitude') - lon = hub.get('longitude') - if lat is None or lon is None: - logger.info("Hub %s has no coordinates", hub_uuid) - return [] + matches = _graph_find_targets( + db, + start_uuid=hub_uuid, + target_predicate=lambda doc: doc.get('node_type') == 'offer', + limit=1000, + max_expansions=Query.MAX_EXPANSIONS, + ) - aql = """ - 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 - } - """ - try: - cursor = db.aql.execute(aql, bind_vars={'lat': lat, 'lon': lon, 'radius_km': radius_km}) - products = [ProductType(uuid=p['uuid'], name=p.get('name')) for p in cursor] - logger.info("Found %d products near hub %s", len(products), hub_uuid) - return products - except Exception as e: - logger.error("Error getting products near hub: %s", e) - return [] + products = {} + for match in matches: + offer = match.get('node') or {} + product_uuid = offer.get('product_uuid') + if not product_uuid: + continue + if product_uuid not in products: + products[product_uuid] = offer.get('product_name') + + result = [ProductType(uuid=uuid, name=name) for uuid, name in products.items()] + logger.info("Found %d products via graph for hub %s", len(result), hub_uuid) + return result def resolve_suppliers_for_product(self, info, product_uuid): """Get unique suppliers that have offers for this product.""" @@ -1179,14 +1148,11 @@ class Query(graphene.ObjectType): def resolve_offers_by_hub(self, info, hub_uuid, product_uuid=None, limit=10): """ Get offers for a product with routes to hub. - Uses phase-based routing: auto → rail* → auto - Search goes from hub outward to find offers. + Uses unified graph routing: auto → rail* → auto """ db = get_db() - ensure_graph() nodes_col = db.collection('nodes') - # Get hub hub = nodes_col.get(hub_uuid) if not hub: logger.info("Hub %s not found", hub_uuid) @@ -1198,158 +1164,35 @@ class Query(graphene.ObjectType): logger.info("Hub %s missing coordinates", hub_uuid) return [] - # Phase-based routing: auto → rail* → auto - def allowed_next_phase(current_phase, transport_type): - """ - Phases — расширение радиуса поиска, ЖД не обязателен: - - end_auto: можно 1 авто, rail, или сразу offer - - end_auto_done: авто использовано — rail или offer - - rail: любое кол-во rail, потом 1 авто или offer - - start_auto_done: авто использовано — только offer - """ - if current_phase == 'end_auto': - if transport_type == 'offer': - return 'offer' - if transport_type == 'auto': - return 'end_auto_done' - if transport_type == 'rail': - return 'rail' - return None - if current_phase == 'end_auto_done': - if transport_type == 'offer': - return 'offer' - if transport_type == 'rail': - return 'rail' - return None - if current_phase == 'rail': - if transport_type == 'offer': - return 'offer' - if transport_type == 'rail': - return 'rail' - if transport_type == 'auto': - return 'start_auto_done' - return None - if current_phase == 'start_auto_done': - if transport_type == 'offer': - return 'offer' - return None - return None - - def fetch_neighbors(node_key, phase): - """Get neighbors based on allowed transport types for phase.""" - if phase == 'end_auto': - types = ['auto', 'rail', 'offer'] - elif phase == 'end_auto_done': - types = ['rail', 'offer'] - elif phase == 'rail': - types = ['rail', 'auto', 'offer'] - elif phase == 'start_auto_done': - types = ['offer'] - else: - types = ['offer'] - - aql = """ - 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 - } - """ - cursor = db.aql.execute( - aql, - bind_vars={ - 'node_id': f"nodes/{node_key}", - 'types': types, - }, - ) - return list(cursor) - - # Priority queue: (cost, seq, node_key, phase) - queue = [] - counter = 0 - heapq.heappush(queue, (0, counter, hub_uuid, 'end_auto')) - - visited = {} - predecessors = {} - node_docs = {hub_uuid: hub} + matches = _graph_find_targets( + db, + start_uuid=hub_uuid, + target_predicate=lambda doc: doc.get('node_type') == 'offer' and ( + product_uuid is None or doc.get('product_uuid') == product_uuid + ), + limit=limit, + max_expansions=Query.MAX_EXPANSIONS, + ) found_routes = [] - expansions = 0 - - while queue and len(found_routes) < limit and expansions < Query.MAX_EXPANSIONS: - cost, _, node_key, phase = heapq.heappop(queue) - - if (node_key, phase) in visited and cost > visited[(node_key, phase)]: - continue - - # Found an offer (optionally filtered by product) - node_doc = node_docs.get(node_key) - if node_doc and node_doc.get('node_type') == 'offer' and ( - product_uuid is None or node_doc.get('product_uuid') == product_uuid - ): - path_edges = [] - state = (node_key, phase) - current_key = node_key - while state in predecessors: - prev_state, edge_info = predecessors[state] - prev_key = prev_state[0] - path_edges.append((current_key, prev_key, edge_info)) - state = prev_state - current_key = prev_key - - route = _build_route_from_edges(path_edges, node_docs) - distance_km = None + for match in matches: + node_doc = match.get('node') or {} + route = match.get('route') + distance_km = match.get('distance_km') + if distance_km is None: src_lat = node_doc.get('latitude') src_lon = node_doc.get('longitude') if src_lat is not None and src_lon is not None: distance_km = _distance_km(src_lat, src_lon, hub_lat, hub_lon) - found_routes.append(ProductRouteOptionType( - source_uuid=node_key, - source_name=node_doc.get('name') or node_doc.get('product_name'), - source_lat=node_doc.get('latitude'), - source_lon=node_doc.get('longitude'), - distance_km=distance_km, - routes=[route] if route else [], - )) - continue - - neighbors = fetch_neighbors(node_key, phase) - expansions += 1 - - for neighbor in neighbors: - transport_type = neighbor.get('transport_type') - next_phase = allowed_next_phase(phase, transport_type) - if next_phase is None: - continue - - travel_time = neighbor.get('travel_time_seconds') - distance_km = neighbor.get('distance_km') - - neighbor_key = neighbor.get('neighbor_key') - node_docs[neighbor_key] = neighbor.get('neighbor_doc') - - step_cost = travel_time if travel_time is not None else (distance_km or 0) - new_cost = cost + step_cost - - state_key = (neighbor_key, next_phase) - if state_key in visited and new_cost >= visited[state_key]: - continue - - visited[state_key] = new_cost - counter += 1 - heapq.heappush(queue, (new_cost, counter, neighbor_key, next_phase)) - predecessors[state_key] = ((node_key, phase), neighbor) + found_routes.append(ProductRouteOptionType( + source_uuid=node_doc.get('_key'), + source_name=node_doc.get('name') or node_doc.get('product_name'), + source_lat=node_doc.get('latitude'), + source_lon=node_doc.get('longitude'), + distance_km=distance_km, + routes=[route] if route else [], + )) if not found_routes: logger.info("No offers found near hub %s", hub_uuid) @@ -1367,78 +1210,31 @@ class Query(graphene.ObjectType): # Graph-based nearest hubs when source_uuid provided if source_uuid: - nodes_col = db.collection('nodes') - start = nodes_col.get(source_uuid) - if not start: - logger.warning("Source node %s not found for nearest hubs, falling back to radius search", source_uuid) - source_uuid = None + start_hub = _resolve_start_hub(db, source_uuid=source_uuid) + if not start_hub: + logger.warning("Source node %s not found for nearest hubs, falling back to coordinate search", source_uuid) else: + start_uuid = start_hub.get('_key') + def is_target_hub(doc): - if doc.get('_key') == source_uuid: + if doc.get('_key') == start_uuid: return False if doc.get('node_type') not in ('logistics', None): return False types = doc.get('transport_types') or [] return ('rail' in types) or ('sea' in types) - def fetch_neighbors(node_key): - aql = """ - FOR edge IN edges - FILTER edge.transport_type IN ['auto', 'rail', 'offer'] - 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, - transport_type: edge.transport_type, - distance_km: edge.distance_km, - travel_time_seconds: edge.travel_time_seconds - } - """ - cursor = db.aql.execute( - aql, - bind_vars={'node_id': f"nodes/{node_key}"}, - ) - return list(cursor) - - queue = [] - counter = 0 - heapq.heappush(queue, (0, counter, source_uuid)) - visited = {} - node_docs = {source_uuid: start} - found = [] - expansions = 0 - - while queue and len(found) < limit and expansions < Query.MAX_EXPANSIONS: - cost, _, node_key = heapq.heappop(queue) - if node_key in visited and cost > visited[node_key]: - continue - visited[node_key] = cost - - node_doc = node_docs.get(node_key) - if node_doc and is_target_hub(node_doc): - found.append(node_doc) - if len(found) >= limit: - break - - neighbors = fetch_neighbors(node_key) - expansions += 1 - for neighbor in neighbors: - neighbor_key = neighbor.get('neighbor_key') - if not neighbor_key: - continue - node_docs[neighbor_key] = neighbor.get('neighbor_doc') - step_cost = neighbor.get('travel_time_seconds') or neighbor.get('distance_km') or 0 - new_cost = cost + step_cost - if neighbor_key in visited and new_cost >= visited[neighbor_key]: - continue - counter += 1 - heapq.heappush(queue, (new_cost, counter, neighbor_key)) + matches = _graph_find_targets( + db, + start_uuid=start_uuid, + target_predicate=is_target_hub, + limit=limit, + max_expansions=Query.MAX_EXPANSIONS, + ) hubs = [] - for node in found: + for match in matches: + node = match.get('node') or {} hubs.append(NodeType( uuid=node.get('_key'), name=node.get('name'), @@ -1449,78 +1245,80 @@ class Query(graphene.ObjectType): synced_at=node.get('synced_at'), transport_types=node.get('transport_types') or [], edges=[], + distance_km=match.get('distance_km'), )) return hubs if product_uuid: return self.resolve_hubs_for_product_graph(info, product_uuid, limit=limit) - # Simple nearest hubs search (no radius filtering) - aql = """ - FOR hub IN nodes - FILTER hub.node_type == 'logistics' OR hub.node_type == null - FILTER hub.product_uuid == null - LET types = hub.transport_types != null ? hub.transport_types : [] - FILTER ('rail' IN types) OR ('sea' IN types) - FILTER hub.latitude != null AND hub.longitude != null - LET dist = DISTANCE(hub.latitude, hub.longitude, @lat, @lon) / 1000 - SORT dist ASC - LIMIT @limit - RETURN MERGE(hub, {distance_km: dist}) - """ - bind_vars = {'lat': lat, 'lon': lon, 'limit': limit} - - try: - cursor = db.aql.execute(aql, bind_vars=bind_vars) - hubs = [] - for node in cursor: - hubs.append(NodeType( - uuid=node['_key'], - name=node.get('name'), - latitude=node.get('latitude'), - longitude=node.get('longitude'), - country=node.get('country'), - country_code=node.get('country_code'), - synced_at=node.get('synced_at'), - transport_types=node.get('transport_types') or [], - edges=[], - distance_km=node.get('distance_km'), - )) - logger.info("Found %d hubs near (%.3f, %.3f)", len(hubs), lat, lon) - return hubs - except Exception as e: - logger.error("Error finding nearest hubs: %s", e) + start_hub = _resolve_start_hub(db, lat=lat, lon=lon) + if not start_hub: return [] + start_uuid = start_hub.get('_key') + + def is_target_hub(doc): + if doc.get('_key') == start_uuid: + return False + if doc.get('node_type') not in ('logistics', None): + return False + types = doc.get('transport_types') or [] + return ('rail' in types) or ('sea' in types) + + matches = _graph_find_targets( + db, + start_uuid=start_uuid, + target_predicate=is_target_hub, + limit=max(limit - 1, 0), + max_expansions=Query.MAX_EXPANSIONS, + ) + + hubs = [ + NodeType( + uuid=start_hub.get('_key'), + name=start_hub.get('name'), + latitude=start_hub.get('latitude'), + longitude=start_hub.get('longitude'), + country=start_hub.get('country'), + country_code=start_hub.get('country_code'), + synced_at=start_hub.get('synced_at'), + transport_types=start_hub.get('transport_types') or [], + edges=[], + distance_km=0, + ) + ] + + for match in matches: + node = match.get('node') or {} + hubs.append(NodeType( + uuid=node.get('_key'), + name=node.get('name'), + latitude=node.get('latitude'), + longitude=node.get('longitude'), + country=node.get('country'), + country_code=node.get('country_code'), + synced_at=node.get('synced_at'), + transport_types=node.get('transport_types') or [], + edges=[], + distance_km=match.get('distance_km'), + )) + + logger.info("Found %d hubs via graph near (%.3f, %.3f)", len(hubs), lat, lon) + return hubs[:limit] + def resolve_nearest_offers(self, info, lat, lon, radius=500, product_uuid=None, hub_uuid=None, limit=50): """Find nearest offers to coordinates, optionally filtered by product. If hub_uuid provided, calculates routes.""" db = get_db() - ensure_graph() - try: nodes_col = db.collection('nodes') - # If no hub_uuid provided, snap to nearest hub by coordinates. - if not hub_uuid: - aql_hub = """ - FOR hub IN nodes - FILTER hub.node_type == 'logistics' OR hub.node_type == null - FILTER hub.product_uuid == null - LET types = hub.transport_types != null ? hub.transport_types : [] - FILTER ('rail' IN types) OR ('sea' IN types) - 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 - """ - cursor = db.aql.execute(aql_hub, bind_vars={'lat': lat, 'lon': lon}) - hubs = list(cursor) - if not hubs: - logger.info("No hub found near coordinates (%.3f, %.3f)", lat, lon) - return [] - hub_uuid = hubs[0]['_key'] + start_hub = _resolve_start_hub(db, source_uuid=hub_uuid, lat=lat, lon=lon) + if not start_hub: + logger.info("No hub found near coordinates (%.3f, %.3f)", lat, lon) + return [] + hub_uuid = start_hub.get('_key') expanded_limit = max(limit * 5, limit) route_options = Query.resolve_offers_by_hub( Query, info, hub_uuid, product_uuid, expanded_limit @@ -1723,33 +1521,49 @@ class Query(graphene.ObjectType): def resolve_route_to_coordinate(self, info, offer_uuid, lat, lon): """Get route from offer to target coordinates (finds nearest hub automatically).""" db = get_db() - - # Find nearest hub to target coordinates - aql_hub = """ - 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 - """ + nodes_col = db.collection('nodes') try: - cursor = db.aql.execute(aql_hub, bind_vars={'lat': lat, 'lon': lon}) - hubs = list(cursor) - if not hubs: + offer = nodes_col.get(offer_uuid) + if not offer: + logger.info("Offer %s not found", offer_uuid) + return None + + nearest_hub = _snap_to_nearest_hub(db, lat, lon) + if not nearest_hub: logger.info("No hub found near coordinates (%.3f, %.3f)", lat, lon) return None - nearest_hub = hubs[0] hub_uuid = nearest_hub['_key'] logger.info("Found nearest hub %s to coordinates (%.3f, %.3f)", hub_uuid, lat, lon) - # Use existing offer_to_hub logic - # Note: in graphene, self is None (root value), so we call as class method - return Query.resolve_offer_to_hub(Query, info, offer_uuid, hub_uuid) + matches = _graph_find_targets( + db, + start_uuid=hub_uuid, + target_predicate=lambda doc: doc.get('_key') == offer_uuid, + limit=1, + max_expansions=Query.MAX_EXPANSIONS, + ) + if not matches: + return None + + match = matches[0] + route = match.get('route') + distance_km = match.get('distance_km') + if distance_km is None: + src_lat = offer.get('latitude') + src_lon = offer.get('longitude') + if src_lat is not None and src_lon is not None: + distance_km = _distance_km(src_lat, src_lon, nearest_hub.get('latitude'), nearest_hub.get('longitude')) + + return ProductRouteOptionType( + source_uuid=offer.get('_key'), + source_name=offer.get('name') or offer.get('product_name'), + source_lat=offer.get('latitude'), + source_lon=offer.get('longitude'), + distance_km=distance_km, + routes=[route] if route else [], + ) except Exception as e: logger.error("Error finding route to coordinates: %s", e) return None @@ -2025,3 +1839,199 @@ def _distance_km(lat1, lon1, lat2, lon2): Query._distance_km = _distance_km + + +def _graph_allowed_next_phase(current_phase, transport_type): + """ + Phase-based routing: auto → rail* → auto. + - end_auto: allow one auto, rail, or offer + - end_auto_done: auto used — rail or offer + - rail: any number of rail, then one auto or offer + - start_auto_done: auto used — only offer + """ + if current_phase == 'end_auto': + if transport_type == 'offer': + return 'offer' + if transport_type == 'auto': + return 'end_auto_done' + if transport_type == 'rail': + return 'rail' + return None + if current_phase == 'end_auto_done': + if transport_type == 'offer': + return 'offer' + if transport_type == 'rail': + return 'rail' + return None + if current_phase == 'rail': + if transport_type == 'offer': + return 'offer' + if transport_type == 'rail': + return 'rail' + if transport_type == 'auto': + return 'start_auto_done' + return None + if current_phase == 'start_auto_done': + if transport_type == 'offer': + return 'offer' + return None + return None + + +def _graph_allowed_types_for_phase(phase): + 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'] + + +def _graph_fetch_neighbors(db, node_key, allowed_types): + aql = """ + 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 + } + """ + cursor = db.aql.execute( + aql, + bind_vars={'node_id': f"nodes/{node_key}", 'types': allowed_types}, + ) + return list(cursor) + + +def _graph_find_targets(db, start_uuid, target_predicate, limit=10, max_expansions=20000): + """Unified graph traversal: auto → rail* → auto, returns routes for target nodes.""" + ensure_graph() + + nodes_col = db.collection('nodes') + start = nodes_col.get(start_uuid) + if not start: + return [] + + queue = [] + counter = 0 + heapq.heappush(queue, (0, counter, start_uuid, 'end_auto')) + + visited = {} + predecessors = {} + node_docs = {start_uuid: start} + found = [] + expansions = 0 + + while queue and len(found) < limit and expansions < max_expansions: + cost, _, node_key, phase = heapq.heappop(queue) + + if (node_key, phase) in visited and cost > visited[(node_key, phase)]: + continue + visited[(node_key, phase)] = cost + + node_doc = node_docs.get(node_key) + if node_doc and target_predicate(node_doc): + path_edges = [] + state = (node_key, phase) + current_key = node_key + while state in predecessors: + prev_state, edge_info = predecessors[state] + prev_key = prev_state[0] + path_edges.append((current_key, prev_key, edge_info)) + state = prev_state + current_key = prev_key + + route = _build_route_from_edges(path_edges, node_docs) + distance_km = route.total_distance_km if route else None + + found.append({ + 'node': node_doc, + 'route': route, + 'distance_km': distance_km, + 'cost': cost, + }) + continue + + neighbors = _graph_fetch_neighbors(db, node_key, _graph_allowed_types_for_phase(phase)) + expansions += 1 + + for neighbor in neighbors: + transport_type = neighbor.get('transport_type') + next_phase = _graph_allowed_next_phase(phase, transport_type) + if next_phase is None: + continue + + travel_time = neighbor.get('travel_time_seconds') + distance_km = neighbor.get('distance_km') + neighbor_key = neighbor.get('neighbor_key') + if not neighbor_key: + continue + + node_docs[neighbor_key] = neighbor.get('neighbor_doc') + step_cost = travel_time if travel_time is not None else (distance_km or 0) + new_cost = cost + step_cost + + state_key = (neighbor_key, next_phase) + if state_key in visited and new_cost >= visited[state_key]: + continue + + counter += 1 + heapq.heappush(queue, (new_cost, counter, neighbor_key, next_phase)) + predecessors[state_key] = ((node_key, phase), neighbor) + + return found + + +def _snap_to_nearest_hub(db, lat, lon): + aql = """ + FOR hub IN nodes + FILTER hub.node_type == 'logistics' OR hub.node_type == null + FILTER hub.product_uuid == null + LET types = hub.transport_types != null ? hub.transport_types : [] + FILTER ('rail' IN types) OR ('sea' IN types) + 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 + """ + cursor = db.aql.execute(aql, bind_vars={'lat': lat, 'lon': lon}) + hubs = list(cursor) + return hubs[0] if hubs else None + + +def _resolve_start_hub(db, source_uuid=None, lat=None, lon=None): + nodes_col = db.collection('nodes') + + if source_uuid: + node = nodes_col.get(source_uuid) + if not node: + return None + + if node.get('node_type') in ('logistics', None): + types = node.get('transport_types') or [] + if ('rail' in types) or ('sea' in types): + return node + + node_lat = node.get('latitude') + node_lon = node.get('longitude') + if node_lat is None or node_lon is None: + return None + return _snap_to_nearest_hub(db, node_lat, node_lon) + + if lat is None or lon is None: + return None + + return _snap_to_nearest_hub(db, lat, lon)