From 07f89ba5fbe897afd483d94dee84fb1de64492f2 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:54:00 +0700 Subject: [PATCH] refactor(geo): Clean up queries - rename offers_to_hub to offers_by_hub, add offer_to_hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove find_routes, find_product_routes, delivery_to_hub queries - Rename offers_to_hub → offers_by_hub with proper phase-based routing (auto → rail* → auto) - Add offer_to_hub query for single offer to hub connection - Both new queries use Dijkstra-like search with transport phases --- geo_app/schema.py | 580 +++++++++++++++++++++++++--------------------- 1 file changed, 320 insertions(+), 260 deletions(-) diff --git a/geo_app/schema.py b/geo_app/schema.py index 4388d81..fc6d3e9 100644 --- a/geo_app/schema.py +++ b/geo_app/schema.py @@ -182,22 +182,6 @@ class Query(graphene.ObjectType): description="Get rail route between two points via OpenRailRouting", ) - find_routes = graphene.List( - RoutePathType, - from_uuid=graphene.String(required=True), - to_uuid=graphene.String(required=True), - limit=graphene.Int(default_value=3), - description="Find K shortest routes through graph between two nodes", - ) - - find_product_routes = graphene.List( - ProductRouteOptionType, - product_uuid=graphene.String(required=True), - to_uuid=graphene.String(required=True), - limit_sources=graphene.Int(default_value=3), - limit_routes=graphene.Int(default_value=3), - description="Find routes from product offer nodes to destination", - ) clustered_nodes = graphene.List( ClusterPointType, @@ -254,19 +238,19 @@ class Query(graphene.ObjectType): description="Get products available near a hub", ) - offers_to_hub = graphene.List( + offers_by_hub = graphene.List( ProductRouteOptionType, hub_uuid=graphene.String(required=True), product_uuid=graphene.String(required=True), limit=graphene.Int(default_value=10), - description="Get offers for a product with routes to hub", + description="Get offers for a product with routes to hub (auto → rail* → auto)", ) - delivery_to_hub = graphene.Field( + offer_to_hub = graphene.Field( ProductRouteOptionType, offer_uuid=graphene.String(required=True), hub_uuid=graphene.String(required=True), - description="Get delivery route from offer to hub", + description="Get route from a specific offer to hub", ) @staticmethod @@ -657,197 +641,6 @@ class Query(graphene.ObjectType): return None - def resolve_find_routes(self, info, from_uuid, to_uuid, limit=3): - """Find K shortest routes through graph using ArangoDB K_SHORTEST_PATHS.""" - db = get_db() - ensure_graph() - return Query._build_routes(db, from_uuid, to_uuid, limit) - - def resolve_find_product_routes(self, info, product_uuid, to_uuid, limit_sources=3, limit_routes=3): - """ - Найти до N ближайших офферов и вернуть по одному маршруту: - авто -> (rail сколько угодно) -> авто. Поиск идёт от точки назначения наружу. - """ - db = get_db() - ensure_graph() # graph exists, но используем ручной обход - - # Load destination node for distance sorting - nodes_col = db.collection('nodes') - dest = nodes_col.get(to_uuid) - if not dest: - logger.info("Destination node %s not found", to_uuid) - return [] - - dest_lat = dest.get('latitude') - dest_lon = dest.get('longitude') - if dest_lat is None or dest_lon is None: - logger.info("Destination node %s missing coordinates", to_uuid) - return [] - - max_sources = limit_sources or 5 - max_routes = 1 # всегда один маршрут на оффер - - # Helpers - 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 - - 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' # нашли после 1 авто - 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): - """Получить соседей с учётом допустимых типов транспорта.""" - # offer доступен на всех фазах — ищем ближайший - 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, to_uuid, 'end_auto')) - - visited = {} # (node, phase) -> best_cost - predecessors = {} # (node, phase) -> (prev_node, prev_phase, edge_info) - node_docs = {to_uuid: dest} - - found_routes = [] - expansions = 0 - - while queue and len(found_routes) < max_sources 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 - - # Если нашли оффер нужного товара в допустимой фазе, фиксируем маршрут - node_doc = node_docs.get(node_key) - if node_doc and 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)) # from source toward dest - state = prev_state - current_key = prev_key - - route = _build_route_from_edges(path_edges, node_docs) - distance_km = 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, dest_lat, dest_lon) - - found_routes.append(ProductRouteOptionType( - source_uuid=node_key, - source_name=node_doc.get('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) - - if not found_routes: - logger.info("No product routes found for %s -> %s", product_uuid, to_uuid) - return [] - - return found_routes - def resolve_clustered_nodes(self, info, west, south, east, north, zoom, transport_type=None): """Get clustered nodes for map display using server-side SuperCluster.""" db = get_db() @@ -1074,12 +867,17 @@ class Query(graphene.ObjectType): logger.error("Error getting products near hub: %s", e) return [] - def resolve_offers_to_hub(self, info, hub_uuid, product_uuid, limit=10): - """Get offers for a product with routes to hub using DISTANCE().""" + def resolve_offers_by_hub(self, info, hub_uuid, product_uuid, 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. + """ db = get_db() + ensure_graph() nodes_col = db.collection('nodes') - # Get hub coordinates + # Get hub hub = nodes_col.get(hub_uuid) if not hub: logger.info("Hub %s not found", hub_uuid) @@ -1091,65 +889,327 @@ class Query(graphene.ObjectType): logger.info("Hub %s missing coordinates", hub_uuid) return [] - # Find offers for this product sorted by distance to hub - aql = """ - FOR node IN nodes - FILTER node.node_type == 'offer' - FILTER node.product_uuid == @product_uuid - FILTER node.latitude != null AND node.longitude != null - LET dist = DISTANCE(node.latitude, node.longitude, @hub_lat, @hub_lon) / 1000 - SORT dist ASC - LIMIT @limit - RETURN MERGE(node, {distance_km: dist}) - """ - try: - cursor = db.aql.execute(aql, bind_vars={ - 'product_uuid': product_uuid, - 'hub_lat': hub_lat, - 'hub_lon': hub_lon, - 'limit': limit - }) - offers = list(cursor) - logger.info("Found %d offers for product %s near hub %s", len(offers), product_uuid, hub_uuid) + # 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 - results = [] - for offer in offers: - # Build route for each offer - routes = Query._build_routes(db, offer['_key'], hub_uuid, limit=1) - results.append(ProductRouteOptionType( - source_uuid=offer['_key'], - source_name=offer.get('name'), - source_lat=offer.get('latitude'), - source_lon=offer.get('longitude'), - distance_km=offer.get('distance_km'), - routes=routes, + 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} + + 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 for the product + node_doc = node_docs.get(node_key) + if node_doc and 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 + 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 [], )) - return results - except Exception as e: - logger.error("Error getting offers to hub: %s", e) + 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) + + if not found_routes: + logger.info("No offers found for product %s near hub %s", product_uuid, hub_uuid) return [] - def resolve_delivery_to_hub(self, info, offer_uuid, hub_uuid): - """Get delivery route from offer to hub.""" + logger.info("Found %d offers for product %s near hub %s", len(found_routes), product_uuid, hub_uuid) + return found_routes + + def resolve_offer_to_hub(self, info, offer_uuid, hub_uuid): + """ + Get route from a specific offer to hub. + Uses phase-based routing: auto → rail* → auto + """ db = get_db() + ensure_graph() nodes_col = db.collection('nodes') + offer = nodes_col.get(offer_uuid) if not offer: logger.info("Offer %s not found", offer_uuid) return None - routes = Query._build_routes(db, offer_uuid, hub_uuid, limit=1) - if not routes: + hub = nodes_col.get(hub_uuid) + if not hub: + logger.info("Hub %s not found", hub_uuid) return None - return ProductRouteOptionType( - source_uuid=offer_uuid, - source_name=offer.get('name'), - source_lat=offer.get('latitude'), - source_lon=offer.get('longitude'), - distance_km=routes[0].total_distance_km if routes else None, - routes=routes, - ) + hub_lat = hub.get('latitude') + hub_lon = hub.get('longitude') + offer_lat = offer.get('latitude') + offer_lon = offer.get('longitude') + + # Phase-based routing from hub to offer + def allowed_next_phase(current_phase, transport_type): + 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): + 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) + + queue = [] + counter = 0 + heapq.heappush(queue, (0, counter, hub_uuid, 'end_auto')) + + visited = {} + predecessors = {} + node_docs = {hub_uuid: hub, offer_uuid: offer} + + expansions = 0 + + while queue 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 the specific offer + if node_key == offer_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 + if offer_lat is not None and offer_lon is not None and hub_lat is not None and hub_lon is not None: + distance_km = _distance_km(offer_lat, offer_lon, hub_lat, hub_lon) + + return ProductRouteOptionType( + source_uuid=offer_uuid, + source_name=offer.get('name') or offer.get('product_name'), + source_lat=offer_lat, + source_lon=offer_lon, + distance_km=distance_km, + routes=[route] if route else [], + ) + + 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) + + logger.info("No route found from offer %s to hub %s", offer_uuid, hub_uuid) + return None schema = graphene.Schema(query=Query)