diff --git a/geo_app/schema.py b/geo_app/schema.py index 760eb09..96ba806 100644 --- a/geo_app/schema.py +++ b/geo_app/schema.py @@ -311,7 +311,7 @@ class Query(graphene.ObjectType): description="Get hubs where a product is available nearby", ) - offers_by_hub = graphene.List( + offers_for_hub = graphene.List( ProductRouteOptionType, hub_uuid=graphene.String(required=True), product_uuid=graphene.String(required=True), @@ -319,13 +319,6 @@ class Query(graphene.ObjectType): description="Get offers for a product with routes to hub (auto → rail* → auto)", ) - offer_to_hub = graphene.Field( - ProductRouteOptionType, - offer_uuid=graphene.String(required=True), - hub_uuid=graphene.String(required=True), - description="Get route from a specific offer to hub", - ) - # New unified endpoints (coordinate-based) nearest_hubs = graphene.List( NodeType, @@ -1124,48 +1117,8 @@ class Query(graphene.ObjectType): return [] def resolve_hubs_for_product(self, info, product_uuid, radius_km=500): - """Get hubs that have this product available within radius.""" - db = get_db() - aql = """ - 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 - } - """ - try: - cursor = db.aql.execute(aql, bind_vars={'product_uuid': product_uuid, 'radius_km': radius_km}) - hubs = [NodeType( - uuid=h['uuid'], - name=h['name'], - latitude=h['latitude'], - longitude=h['longitude'], - country=h['country'], - country_code=h.get('country_code'), - transport_types=h.get('transport_types') - ) for h in cursor] - logger.info("Found %d hubs for product %s", len(hubs), product_uuid) - return hubs - except Exception as e: - logger.error("Error getting hubs for product: %s", e) - return [] + """Get hubs that can accept this product (graph-based).""" + return self.resolve_hubs_for_product_graph(info, product_uuid) def resolve_hubs_for_product_graph(self, info, product_uuid, limit=500): """Get hubs that can be reached by graph routes for a product.""" @@ -1219,7 +1172,11 @@ class Query(graphene.ObjectType): logger.error("Error getting graph hubs for product %s: %s", product_uuid, e) return [] - def resolve_offers_by_hub(self, info, hub_uuid, product_uuid, limit=10): + def resolve_offers_for_hub(self, info, hub_uuid, product_uuid, limit=10): + """Alias for offers by hub (graph-based).""" + return self.resolve_offers_by_hub(info, hub_uuid, product_uuid, limit=limit) + + 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 @@ -1335,9 +1292,11 @@ class Query(graphene.ObjectType): if (node_key, phase) in visited and cost > visited[(node_key, phase)]: continue - # Found an offer for the product + # Found an offer (optionally filtered by product) node_doc = node_docs.get(node_key) - if node_doc and node_doc.get('product_uuid') == product_uuid: + 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 @@ -1393,176 +1352,15 @@ class Query(graphene.ObjectType): 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) + logger.info("No offers found near hub %s", hub_uuid) return [] - logger.info("Found %d offers for product %s near hub %s", len(found_routes), product_uuid, hub_uuid) + if product_uuid: + logger.info("Found %d offers for product %s near hub %s", len(found_routes), product_uuid, hub_uuid) + else: + logger.info("Found %d offers near hub %s", len(found_routes), 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 - - hub = nodes_col.get(hub_uuid) - if not hub: - logger.info("Hub %s not found", hub_uuid) - return None - - 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 - def resolve_nearest_hubs(self, info, lat, lon, radius=1000, product_uuid=None, source_uuid=None, use_graph=False, limit=12): """Find nearest hubs to coordinates, optionally filtered by product availability.""" db = get_db() @@ -1654,51 +1452,23 @@ class Query(graphene.ObjectType): )) return hubs - if product_uuid and use_graph: + if product_uuid: return self.resolve_hubs_for_product_graph(info, product_uuid, limit=limit) - if product_uuid: - # Find hubs that have offers for this product within radius - 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 - 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_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}) - """ - bind_vars = {'lat': lat, 'lon': lon, 'radius': radius, 'product_uuid': product_uuid, 'limit': limit} - else: - # Simple nearest hubs search - 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 - FILTER dist <= @radius - SORT dist ASC - LIMIT @limit - RETURN MERGE(hub, {distance_km: dist}) - """ - bind_vars = {'lat': lat, 'lon': lon, 'radius': radius, '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) @@ -1716,7 +1486,7 @@ class Query(graphene.ObjectType): edges=[], distance_km=node.get('distance_km'), )) - logger.info("Found %d hubs near (%.3f, %.3f) within %d km", len(hubs), lat, lon, radius) + 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) @@ -1727,91 +1497,41 @@ class Query(graphene.ObjectType): db = get_db() ensure_graph() - # If hub_uuid + product_uuid provided, use graph search to return only offers with routes. - if hub_uuid and product_uuid: - try: - nodes_col = db.collection('nodes') - expanded_limit = max(limit * 5, limit) - route_options = Query.resolve_offers_by_hub( - Query, info, hub_uuid, product_uuid, expanded_limit - ) - offers = [] - for option in route_options or []: - if not option.routes: - continue - node = nodes_col.get(option.source_uuid) - if not node: - continue - offers.append(OfferWithRouteType( - uuid=node['_key'], - product_uuid=node.get('product_uuid'), - product_name=node.get('product_name'), - supplier_uuid=node.get('supplier_uuid'), - supplier_name=node.get('supplier_name'), - 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'), - distance_km=option.distance_km, - routes=option.routes, - )) - if len(offers) >= limit: - break - logger.info("Found %d offers by graph for hub %s", len(offers), hub_uuid) - return offers - except Exception as e: - logger.error("Error finding offers by hub %s: %s", hub_uuid, e) - return [] - - aql = """ - FOR offer IN nodes - FILTER offer.node_type == 'offer' - FILTER offer.product_uuid != null - FILTER offer.latitude != null AND offer.longitude != null - """ - - if product_uuid: - 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}) - """ - - bind_vars = {'lat': lat, 'lon': lon, 'radius': radius, 'limit': limit} - if product_uuid: - bind_vars['product_uuid'] = product_uuid - try: - cursor = db.aql.execute(aql, bind_vars=bind_vars) - offer_nodes = list(cursor) - logger.info("Found %d offers near (%.3f, %.3f) within %d km", len(offer_nodes), lat, lon, radius) + nodes_col = db.collection('nodes') - # If hub_uuid provided, calculate routes for each offer - if hub_uuid: - nodes_col = db.collection('nodes') - hub = nodes_col.get(hub_uuid) - if not hub: - logger.warning("Hub %s not found for route calculation", hub_uuid) - hub_uuid = None + # 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'] + expanded_limit = max(limit * 5, limit) + route_options = Query.resolve_offers_by_hub( + Query, info, hub_uuid, product_uuid, expanded_limit + ) offers = [] - for node in offer_nodes: - routes = [] - - # Calculate route to hub if hub_uuid provided - if hub_uuid: - route_result = Query.resolve_offer_to_hub(Query, info, node['_key'], hub_uuid) - if route_result and route_result.routes: - routes = route_result.routes - + for option in route_options or []: + if not option.routes: + continue + node = nodes_col.get(option.source_uuid) + if not node: + continue offers.append(OfferWithRouteType( uuid=node['_key'], product_uuid=node.get('product_uuid'), @@ -1826,10 +1546,12 @@ class Query(graphene.ObjectType): currency=node.get('currency'), quantity=node.get('quantity'), unit=node.get('unit'), - distance_km=node.get('distance_km'), - routes=routes, + distance_km=option.distance_km, + routes=option.routes, )) - + if len(offers) >= limit: + break + logger.info("Found %d offers by graph for hub %s", len(offers), hub_uuid) return offers except Exception as e: logger.error("Error finding nearest offers: %s", e) @@ -1920,45 +1642,63 @@ class Query(graphene.ObjectType): """Find nearest suppliers to coordinates, optionally filtered by product.""" db = get_db() - aql = """ - FOR offer IN nodes - FILTER offer.node_type == 'offer' - FILTER offer.supplier_uuid != null - FILTER offer.latitude != null AND offer.longitude != null - """ - if product_uuid: - aql += " FILTER offer.product_uuid == @product_uuid\n" + aql = """ + FOR offer IN nodes + FILTER offer.node_type == 'offer' + FILTER offer.supplier_uuid != null + FILTER offer.product_uuid == @product_uuid + COLLECT supplier_uuid = offer.supplier_uuid INTO offers + LET first_offer = FIRST(offers).offer - 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 + // Try to find supplier node for full info + LET supplier_node = FIRST( + FOR s IN nodes + FILTER s._key == supplier_uuid + FILTER s.node_type == 'supplier' + RETURN s + ) - // Try to find supplier node for full info - LET supplier_node = FIRST( - FOR s IN nodes - FILTER s._key == supplier_uuid - FILTER s.node_type == 'supplier' - RETURN s - ) + 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, + distance_km: null + } + """ + bind_vars = {'product_uuid': product_uuid, 'limit': limit} + else: + aql = """ + FOR offer IN nodes + FILTER offer.node_type == 'offer' + FILTER offer.supplier_uuid != null + FILTER offer.latitude != null AND offer.longitude != null + LET dist = DISTANCE(offer.latitude, offer.longitude, @lat, @lon) / 1000 + 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 - 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, - distance_km: supplier_dist - } - """ + // Try to find supplier node for full info + LET supplier_node = FIRST( + FOR s IN nodes + FILTER s._key == supplier_uuid + FILTER s.node_type == 'supplier' + RETURN s + ) - bind_vars = {'lat': lat, 'lon': lon, 'radius': radius, 'limit': limit} - if product_uuid: - bind_vars['product_uuid'] = product_uuid + 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, + distance_km: supplier_dist + } + """ + bind_vars = {'lat': lat, 'lon': lon, 'limit': limit} try: cursor = db.aql.execute(aql, bind_vars=bind_vars) @@ -1971,7 +1711,10 @@ class Query(graphene.ObjectType): longitude=s.get('longitude'), distance_km=s.get('distance_km'), )) - logger.info("Found %d suppliers near (%.3f, %.3f) within %d km", len(suppliers), lat, lon, radius) + if product_uuid: + logger.info("Found %d suppliers for product %s", len(suppliers), product_uuid) + else: + logger.info("Found %d suppliers near (%.3f, %.3f)", len(suppliers), lat, lon) return suppliers except Exception as e: logger.error("Error finding nearest suppliers: %s", e)