From b6f9b2d70b7b386867e74012edd9e2fa901d5949 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:39:55 +0700 Subject: [PATCH] Replace graph traversal queries with DISTANCE() queries - Add new resolvers: products, offersByProduct, hubsNearOffer, suppliers, productsBySupplier, offersBySupplierProduct, productsNearHub, offersToHub, deliveryToHub - Remove broken queries that caused OOM on 234k edges - Use DISTANCE() for geographic proximity instead of graph traversal --- geo_app/schema.py | 466 ++++++++++++++++++++++++++++++---------------- 1 file changed, 301 insertions(+), 165 deletions(-) diff --git a/geo_app/schema.py b/geo_app/schema.py index 590e142..6ae9e8d 100644 --- a/geo_app/schema.py +++ b/geo_app/schema.py @@ -91,6 +91,33 @@ class ClusterPointType(graphene.ObjectType): name = graphene.String(description="Node name (only for single points)") +class ProductType(graphene.ObjectType): + """Unique product from offers.""" + uuid = graphene.String() + name = graphene.String() + + +class SupplierType(graphene.ObjectType): + """Unique supplier from offers.""" + uuid = graphene.String() + + +class OfferNodeType(graphene.ObjectType): + """Offer node with location and product info.""" + uuid = graphene.String() + product_uuid = graphene.String() + product_name = graphene.String() + supplier_uuid = graphene.String() + latitude = graphene.Float() + longitude = graphene.Float() + country = graphene.String() + country_code = graphene.String() + price_per_unit = graphene.String() + currency = graphene.String() + quantity = graphene.String() + unit = graphene.String() + + class Query(graphene.ObjectType): """Root query.""" MAX_EXPANSIONS = 20000 @@ -183,32 +210,63 @@ class Query(graphene.ObjectType): description="Get clustered nodes for map display (server-side clustering)", ) - # Business-oriented queries for catalog navigation - find_products_for_hub = graphene.List( - graphene.String, - hub_uuid=graphene.String(required=True), - description="Find unique product UUIDs that can be delivered to this hub", + # Catalog navigation queries + products = graphene.List( + ProductType, + description="Get unique products from all offers", ) - find_hubs_for_product = graphene.List( - NodeType, + offers_by_product = graphene.List( + OfferNodeType, product_uuid=graphene.String(required=True), - description="Find logistics hubs where this product can be delivered", + description="Get all offers for a product", ) - find_supplier_product_hubs = graphene.List( + hubs_near_offer = graphene.List( NodeType, + offer_uuid=graphene.String(required=True), + limit=graphene.Int(default_value=12), + description="Get nearest hubs to an offer location", + ) + + suppliers = graphene.List( + SupplierType, + description="Get unique suppliers from all offers", + ) + + products_by_supplier = graphene.List( + ProductType, + supplier_uuid=graphene.String(required=True), + description="Get products offered by a supplier", + ) + + offers_by_supplier_product = graphene.List( + OfferNodeType, supplier_uuid=graphene.String(required=True), product_uuid=graphene.String(required=True), - description="Find hubs where this supplier can deliver this product", + description="Get offers from a supplier for a specific product", ) - find_offers_for_hub_by_product = graphene.List( + products_near_hub = graphene.List( + ProductType, + hub_uuid=graphene.String(required=True), + radius_km=graphene.Float(default_value=500), + description="Get products available near a hub", + ) + + offers_to_hub = graphene.List( ProductRouteOptionType, hub_uuid=graphene.String(required=True), product_uuid=graphene.String(required=True), - limit_sources=graphene.Int(default_value=10), - description="Find product offers that can be delivered to hub (with routes)", + limit=graphene.Int(default_value=10), + description="Get offers for a product with routes to hub", + ) + + delivery_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", ) @staticmethod @@ -796,180 +854,258 @@ class Query(graphene.ObjectType): clusters = get_clustered_nodes(db, west, south, east, north, zoom, transport_type) return [ClusterPointType(**c) for c in clusters] - def resolve_find_products_for_hub(self, info, hub_uuid): - """ - Find unique product UUIDs that can be delivered to this hub. - Uses reverse traversal from hub to find reachable offer nodes. - """ + def resolve_products(self, info): + """Get unique products from all offers.""" db = get_db() - ensure_graph() + aql = """ + 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 + } + """ + try: + cursor = db.aql.execute(aql) + products = [ProductType(uuid=p['uuid'], name=p.get('name')) for p in cursor] + logger.info("Found %d unique products", len(products)) + return products + except Exception as e: + logger.error("Error getting products: %s", e) + return [] - # Check hub exists + def resolve_offers_by_product(self, info, product_uuid): + """Get all offers for a product.""" + db = get_db() + aql = """ + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.product_uuid == @product_uuid + RETURN node + """ + try: + cursor = db.aql.execute(aql, bind_vars={'product_uuid': product_uuid}) + offers = [] + for node in cursor: + offers.append(OfferNodeType( + uuid=node['_key'], + product_uuid=node.get('product_uuid'), + product_name=node.get('product_name'), + supplier_uuid=node.get('supplier_uuid'), + latitude=node.get('latitude'), + longitude=node.get('longitude'), + country=node.get('country'), + country_code=node.get('country_code'), + price_per_unit=node.get('price_per_unit'), + currency=node.get('currency'), + quantity=node.get('quantity'), + unit=node.get('unit'), + )) + logger.info("Found %d offers for product %s", len(offers), product_uuid) + return offers + except Exception as e: + logger.error("Error getting offers by product: %s", e) + return [] + + def resolve_hubs_near_offer(self, info, offer_uuid, limit=12): + """Get nearest hubs to an offer location.""" + db = get_db() + nodes_col = db.collection('nodes') + offer = nodes_col.get(offer_uuid) + if not offer: + logger.info("Offer %s not found", offer_uuid) + return [] + + lat = offer.get('latitude') + lon = offer.get('longitude') + if lat is None or lon is None: + 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 [] + + def resolve_suppliers(self, info): + """Get unique suppliers from all offers.""" + db = get_db() + aql = """ + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.supplier_uuid != null + COLLECT supplier_uuid = node.supplier_uuid + RETURN { uuid: supplier_uuid } + """ + try: + cursor = db.aql.execute(aql) + suppliers = [SupplierType(uuid=s['uuid']) for s in cursor] + logger.info("Found %d unique suppliers", len(suppliers)) + return suppliers + except Exception as e: + logger.error("Error getting suppliers: %s", e) + return [] + + def resolve_products_by_supplier(self, info, supplier_uuid): + """Get products offered by a supplier.""" + db = get_db() + aql = """ + 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 + } + """ + try: + cursor = db.aql.execute(aql, bind_vars={'supplier_uuid': supplier_uuid}) + products = [ProductType(uuid=p['uuid'], name=p.get('name')) for p in cursor] + logger.info("Found %d products for supplier %s", len(products), supplier_uuid) + return products + except Exception as e: + logger.error("Error getting products by supplier: %s", e) + return [] + + def resolve_offers_by_supplier_product(self, info, supplier_uuid, product_uuid): + """Get offers from a supplier for a specific product.""" + db = get_db() + aql = """ + FOR node IN nodes + FILTER node.node_type == 'offer' + FILTER node.supplier_uuid == @supplier_uuid + FILTER node.product_uuid == @product_uuid + RETURN node + """ + try: + cursor = db.aql.execute(aql, bind_vars={ + 'supplier_uuid': supplier_uuid, + 'product_uuid': product_uuid + }) + offers = [] + for node in cursor: + offers.append(OfferNodeType( + uuid=node['_key'], + product_uuid=node.get('product_uuid'), + product_name=node.get('product_name'), + supplier_uuid=node.get('supplier_uuid'), + latitude=node.get('latitude'), + longitude=node.get('longitude'), + country=node.get('country'), + country_code=node.get('country_code'), + price_per_unit=node.get('price_per_unit'), + currency=node.get('currency'), + quantity=node.get('quantity'), + unit=node.get('unit'), + )) + logger.info("Found %d offers for supplier %s product %s", len(offers), supplier_uuid, product_uuid) + return offers + except Exception as e: + logger.error("Error getting offers by supplier product: %s", e) + return [] + + def resolve_products_near_hub(self, info, hub_uuid, radius_km=500): + """Get products available near a hub (within radius).""" + db = get_db() nodes_col = db.collection('nodes') hub = nodes_col.get(hub_uuid) if not hub: logger.info("Hub %s not found", hub_uuid) return [] - # Find all offer nodes reachable from hub via graph traversal - # Offer nodes have product_uuid field + 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 [] + aql = """ - FOR v, e, p IN 1..10 ANY @hub_id GRAPH 'optovia_graph' - FILTER v.product_uuid != null - COLLECT product_uuid = v.product_uuid - RETURN product_uuid - """ - try: - cursor = db.aql.execute( - aql, - bind_vars={'hub_id': f'nodes/{hub_uuid}'}, - ) - product_uuids = list(cursor) - logger.info("Found %d products for hub %s", len(product_uuids), hub_uuid) - return product_uuids - except Exception as e: - logger.error("Error finding products for hub: %s", e) - return [] - - def resolve_find_hubs_for_product(self, info, product_uuid): - """ - Find logistics hubs where this product can be delivered. - Finds offer nodes with this product, then finds reachable hubs. - """ - db = get_db() - ensure_graph() - - # Find all offer nodes with this product_uuid - aql_offers = """ FOR node IN nodes - FILTER node.product_uuid == @product_uuid - RETURN node._key + 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_offers, bind_vars={'product_uuid': product_uuid}) - offer_keys = list(cursor) + 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 finding offers for product: %s", e) + logger.error("Error getting products near hub: %s", e) return [] - if not offer_keys: - logger.info("No offers found for product %s", product_uuid) - return [] - - # Find hubs reachable from these offer nodes - aql_hubs = """ - LET offer_ids = @offer_ids - FOR offer_id IN offer_ids - FOR v, e, p IN 1..10 ANY CONCAT('nodes/', offer_id) GRAPH 'optovia_graph' - FILTER (v.node_type == 'logistics' OR v.node_type == null) - FILTER v.product_uuid == null - COLLECT hub_key = v._key INTO hubs - LET hub = FIRST(hubs).v - RETURN hub - """ - try: - cursor = db.aql.execute(aql_hubs, bind_vars={'offer_ids': offer_keys}) - hubs = list(cursor) - - result = [] - for hub in hubs: - if hub: - result.append(NodeType( - uuid=hub.get('_key'), - name=hub.get('name'), - latitude=hub.get('latitude'), - longitude=hub.get('longitude'), - country=hub.get('country'), - country_code=hub.get('country_code'), - synced_at=hub.get('synced_at'), - transport_types=hub.get('transport_types') or [], - edges=[], - )) - - logger.info("Found %d hubs for product %s", len(result), product_uuid) - return result - except Exception as e: - logger.error("Error finding hubs for product: %s", e) - return [] - - def resolve_find_supplier_product_hubs(self, info, supplier_uuid, product_uuid): - """ - Find hubs where this supplier can deliver this product. - Finds offer nodes matching both supplier and product, then finds reachable hubs. - """ - db = get_db() - ensure_graph() - - # Find offer nodes with this supplier_uuid AND product_uuid - aql_offers = """ - FOR node IN nodes - FILTER node.product_uuid == @product_uuid - FILTER node.supplier_uuid == @supplier_uuid - RETURN node._key - """ - try: - cursor = db.aql.execute( - aql_offers, - bind_vars={'product_uuid': product_uuid, 'supplier_uuid': supplier_uuid} - ) - offer_keys = list(cursor) - except Exception as e: - logger.error("Error finding supplier offers: %s", e) - return [] - - if not offer_keys: - logger.info("No offers found for supplier %s, product %s", supplier_uuid, product_uuid) - return [] - - # Find hubs reachable from these offer nodes - aql_hubs = """ - LET offer_ids = @offer_ids - FOR offer_id IN offer_ids - FOR v, e, p IN 1..10 ANY CONCAT('nodes/', offer_id) GRAPH 'optovia_graph' - FILTER (v.node_type == 'logistics' OR v.node_type == null) - FILTER v.product_uuid == null - COLLECT hub_key = v._key INTO hubs - LET hub = FIRST(hubs).v - RETURN hub - """ - try: - cursor = db.aql.execute(aql_hubs, bind_vars={'offer_ids': offer_keys}) - hubs = list(cursor) - - result = [] - for hub in hubs: - if hub: - result.append(NodeType( - uuid=hub.get('_key'), - name=hub.get('name'), - latitude=hub.get('latitude'), - longitude=hub.get('longitude'), - country=hub.get('country'), - country_code=hub.get('country_code'), - synced_at=hub.get('synced_at'), - transport_types=hub.get('transport_types') or [], - edges=[], - )) - - logger.info("Found %d hubs for supplier %s product %s", len(result), supplier_uuid, product_uuid) - return result - except Exception as e: - logger.error("Error finding supplier product hubs: %s", e) - return [] - - def resolve_find_offers_for_hub_by_product(self, info, hub_uuid, product_uuid, limit_sources=10): - """ - Find product offers that can be delivered to hub (with routes). - Same as find_product_routes but with business-oriented naming. - """ + def resolve_offers_to_hub(self, info, hub_uuid, product_uuid, limit=10): + """Get offers for a product with routes to hub.""" return self.resolve_find_product_routes( info, product_uuid=product_uuid, to_uuid=hub_uuid, - limit_sources=limit_sources, + limit_sources=limit, limit_routes=1, ) + def resolve_delivery_to_hub(self, info, offer_uuid, hub_uuid): + """Get delivery route from offer to hub.""" + db = get_db() + 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 = self._build_routes(db, offer_uuid, hub_uuid, limit=1) + if not routes: + 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, + ) + schema = graphene.Schema(query=Query)