Unify hub offer queries and drop radius filters
All checks were successful
Build Docker Image / build (push) Successful in 2m7s

This commit is contained in:
Ruslan Bakiev
2026-02-07 11:05:52 +07:00
parent 3648366ebe
commit e99cbf4882

View File

@@ -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)