Refactor catalog to use coordinate-based GraphQL endpoints
All checks were successful
Build Docker Image / build (push) Successful in 3m33s

Replace entity-specific queries (GetProductsNearHub, GetOffersByHub, GetHubsForProduct, GetSuppliersForProduct) with unified coordinate-based endpoints (NearestHubs, NearestOffers, NearestSuppliers, RouteToCoordinate). This simplifies backend architecture from 18 to 8 core endpoints while maintaining identical UI/UX behavior.

All composables and pages now use coordinates + client-side grouping instead of specialized backend queries. For global product filtering, uses center point (0,0) with 20000km radius.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-01-25 17:39:33 +07:00
parent 7403d4f063
commit 50375f2a74
6 changed files with 372 additions and 93 deletions

View File

@@ -64,7 +64,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GetOffersByHubDocument } from '~/composables/graphql/public/geo-generated' import { GetNodeDocument, NearestOffersDocument, RouteToCoordinateDocument } from '~/composables/graphql/public/geo-generated'
import type { RouteStageItem } from '~/components/RouteStagesList.vue' import type { RouteStageItem } from '~/components/RouteStagesList.vue'
import { GetOfferDocument, GetSupplierProfileByTeamDocument } from '~/composables/graphql/public/exchange-generated' import { GetOfferDocument, GetSupplierProfileByTeamDocument } from '~/composables/graphql/public/exchange-generated'
@@ -106,17 +106,68 @@ type ProductRouteOption = {
const fetchOffersByHub = async () => { const fetchOffersByHub = async () => {
if (!productUuid.value || !destinationUuid.value) return null if (!productUuid.value || !destinationUuid.value) return null
const { client } = useApolloClient('publicGeo')
const { data } = await client.query({ // 1. Get hub node to get coordinates
query: GetOffersByHubDocument, const hubData = await execute(GetNodeDocument, { uuid: destinationUuid.value }, 'public', 'geo')
variables: { const hub = hubData?.node
hubUuid: destinationUuid.value,
if (!hub?.latitude || !hub?.longitude) {
console.warn('Hub has no coordinates')
return null
}
// 2. Find offers near hub for this product
const offersData = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
productUuid: productUuid.value, productUuid: productUuid.value,
radius: 500,
limit: 5 limit: 5
},
'public',
'geo'
)
const offers = offersData?.nearestOffers || []
// 3. For each offer, get route to hub coordinates
const offersWithRoutes = await Promise.all(
offers.map(async (offer: any) => {
try {
const routeData = await execute(
RouteToCoordinateDocument,
{
offerUuid: offer.uuid,
lat: hub.latitude,
lon: hub.longitude
},
'public',
'geo'
)
return {
sourceUuid: offer.uuid,
sourceName: offer.productName,
sourceLat: offer.latitude,
sourceLon: offer.longitude,
distanceKm: routeData?.routeToCoordinate?.distanceKm,
routes: routeData?.routeToCoordinate?.routes || []
}
} catch (e) {
console.warn('No route found for offer:', offer.uuid, e)
return {
sourceUuid: offer.uuid,
sourceName: offer.productName,
sourceLat: offer.latitude,
sourceLon: offer.longitude,
routes: []
}
} }
}) })
return data )
return { offersByHub: offersWithRoutes }
} }
const { data: productRoutesData, pending, error } = await useAsyncData( const { data: productRoutesData, pending, error } = await useAsyncData(

View File

@@ -1,4 +1,4 @@
import { GetNodesDocument, GetHubCountriesDocument, GetHubsForProductDocument } from '~/composables/graphql/public/geo-generated' import { GetNodesDocument, GetHubCountriesDocument, NearestHubsDocument } from '~/composables/graphql/public/geo-generated'
const PAGE_SIZE = 24 const PAGE_SIZE = 24
@@ -52,15 +52,22 @@ export function useCatalogHubs() {
const fetchPage = async (offset: number, replace = false) => { const fetchPage = async (offset: number, replace = false) => {
if (replace) isLoading.value = true if (replace) isLoading.value = true
try { try {
// If filtering by product, use specialized query // If filtering by product, use nearestHubs with global search
// (center point 0,0 with very large radius to cover entire globe)
if (filterProductUuid.value) { if (filterProductUuid.value) {
const data = await execute( const data = await execute(
GetHubsForProductDocument, NearestHubsDocument,
{ productUuid: filterProductUuid.value }, {
lat: 0,
lon: 0,
radius: 20000, // 20000 km radius covers entire Earth
productUuid: filterProductUuid.value,
limit: 500 // Increased limit for global search
},
'public', 'public',
'geo' 'geo'
) )
const next = data?.hubsForProduct || [] const next = data?.nearestHubs || []
items.value = next items.value = next
total.value = next.length total.value = next.length
isInitialized.value = true isInitialized.value = true

View File

@@ -1,11 +1,9 @@
import type { InfoEntityType } from './useCatalogSearch' import type { InfoEntityType } from './useCatalogSearch'
import { import {
GetNodeDocument, GetNodeDocument,
GetProductsNearHubDocument, NearestOffersDocument,
GetProductsBySupplierDocument, NearestHubsDocument,
GetHubsNearOfferDocument, RouteToCoordinateDocument
GetOffersByHubDocument,
GetOffersBySupplierProductDocument
} from '~/composables/graphql/public/geo-generated' } from '~/composables/graphql/public/geo-generated'
import { import {
GetOfferDocument, GetOfferDocument,
@@ -32,17 +30,41 @@ export function useCatalogInfo() {
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo') const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
entity.value = nodeData?.node entity.value = nodeData?.node
// Load products near hub if (!entity.value?.latitude || !entity.value?.longitude) {
const productsData = await execute( console.warn('Hub has no coordinates')
GetProductsNearHubDocument, return
{ hubUuid: uuid, radiusKm: 500 }, }
// Load offers near hub and group by product
const offersData = await execute(
NearestOffersDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 500
},
'public', 'public',
'geo' 'geo'
) )
relatedProducts.value = productsData?.productsNearHub || []
// Set default active tab to products // Group offers by product
activeTab.value = 'products' const productsMap = new Map<string, any>()
offersData?.nearestOffers?.forEach((offer: any) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
})
}
productsMap.get(offer.productUuid)!.offersCount++
}
})
relatedProducts.value = Array.from(productsMap.values())
// Set default active tab to offers (first step shows products)
activeTab.value = 'offers'
// Note: Suppliers loaded after product selection via loadOffersForHub // Note: Suppliers loaded after product selection via loadOffersForHub
} catch (error) { } catch (error) {
@@ -53,7 +75,7 @@ export function useCatalogInfo() {
// Load supplier info: supplier details + products // Load supplier info: supplier details + products
const loadSupplierInfo = async (uuid: string) => { const loadSupplierInfo = async (uuid: string) => {
try { try {
// Load supplier node details // Load supplier node details (might be geo node)
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo') const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
entity.value = nodeData?.node entity.value = nodeData?.node
@@ -72,17 +94,41 @@ export function useCatalogInfo() {
// Supplier profile might not exist, ignore // Supplier profile might not exist, ignore
} }
// Load products from supplier if (!entity.value?.latitude || !entity.value?.longitude) {
const productsData = await execute( console.warn('Supplier has no coordinates')
GetProductsBySupplierDocument, return
{ supplierUuid: uuid }, }
// Load offers near supplier and group by product
const offersData = await execute(
NearestOffersDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 500
},
'public', 'public',
'geo' 'geo'
) )
relatedProducts.value = productsData?.productsBySupplier || []
// Set default active tab to products // Group offers by product
activeTab.value = 'products' const productsMap = new Map<string, any>()
offersData?.nearestOffers?.forEach((offer: any) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
})
}
productsMap.get(offer.productUuid)!.offersCount++
}
})
relatedProducts.value = Array.from(productsMap.values())
// Set default active tab to offers (first step shows products)
activeTab.value = 'offers'
// Note: Hubs will be loaded after product selection // Note: Hubs will be loaded after product selection
} catch (error) { } catch (error) {
@@ -97,14 +143,24 @@ export function useCatalogInfo() {
const offerData = await execute(GetOfferDocument, { uuid }, 'public', 'exchange') const offerData = await execute(GetOfferDocument, { uuid }, 'public', 'exchange')
entity.value = offerData?.getOffer entity.value = offerData?.getOffer
// Load hubs near offer if (!entity.value?.latitude || !entity.value?.longitude) {
console.warn('Offer has no coordinates')
return
}
// Load hubs near offer coordinates
const hubsData = await execute( const hubsData = await execute(
GetHubsNearOfferDocument, NearestHubsDocument,
{ offerUuid: uuid, limit: 12 }, {
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 1000,
limit: 12
},
'public', 'public',
'geo' 'geo'
) )
relatedHubs.value = hubsData?.hubsNearOffer || [] relatedHubs.value = hubsData?.nearestHubs || []
// If offer has supplier UUID, load supplier profile // If offer has supplier UUID, load supplier profile
if (entity.value?.teamUuid) { if (entity.value?.teamUuid) {
@@ -143,19 +199,73 @@ export function useCatalogInfo() {
// Load offers for hub after product selection // Load offers for hub after product selection
const loadOffersForHub = async (hubUuid: string, productUuid: string) => { const loadOffersForHub = async (hubUuid: string, productUuid: string) => {
try { try {
const hub = entity.value
if (!hub?.latitude || !hub?.longitude) {
console.warn('Hub has no coordinates')
return
}
// 1. Find offers near hub for this product
const offersData = await execute( const offersData = await execute(
GetOffersByHubDocument, NearestOffersDocument,
{ hubUuid, productUuid, limit: 12 }, {
lat: hub.latitude,
lon: hub.longitude,
productUuid,
radius: 500,
limit: 12
},
'public', 'public',
'geo' 'geo'
) )
relatedOffers.value = offersData?.offersByHub || []
// Extract unique suppliers from offers const offers = offersData?.nearestOffers || []
// 2. For each offer, get route to hub
const offersWithRoutes = await Promise.all(
offers.map(async (offer: any) => {
try {
const routeData = await execute(
RouteToCoordinateDocument,
{
offerUuid: offer.uuid,
lat: hub.latitude,
lon: hub.longitude
},
'public',
'geo'
)
return {
...offer,
sourceUuid: offer.uuid,
sourceName: offer.productName,
sourceLat: offer.latitude,
sourceLon: offer.longitude,
distanceKm: routeData?.routeToCoordinate?.distanceKm,
routes: routeData?.routeToCoordinate?.routes || []
}
} catch (e) {
// Route might not exist
console.warn('No route found for offer:', offer.uuid, e)
return {
...offer,
sourceUuid: offer.uuid,
sourceName: offer.productName,
sourceLat: offer.latitude,
sourceLon: offer.longitude,
routes: []
}
}
})
)
relatedOffers.value = offersWithRoutes
// Extract unique suppliers from offers (use supplierUuid from offers)
const supplierUuids = new Set<string>() const supplierUuids = new Set<string>()
relatedOffers.value.forEach((offer: any) => { offersWithRoutes.forEach((offer: any) => {
if (offer.teamUuid) { if (offer.supplierUuid) {
supplierUuids.add(offer.teamUuid) supplierUuids.add(offer.supplierUuid)
} }
}) })
@@ -185,32 +295,53 @@ export function useCatalogInfo() {
// Load offers for supplier after product selection // Load offers for supplier after product selection
const loadOffersForSupplier = async (supplierUuid: string, productUuid: string) => { const loadOffersForSupplier = async (supplierUuid: string, productUuid: string) => {
try { try {
const supplier = entity.value
if (!supplier?.latitude || !supplier?.longitude) {
console.warn('Supplier has no coordinates')
return
}
// Find offers near supplier for this product
const offersData = await execute( const offersData = await execute(
GetOffersBySupplierProductDocument, NearestOffersDocument,
{ supplierUuid, productUuid }, {
lat: supplier.latitude,
lon: supplier.longitude,
productUuid,
radius: 500,
limit: 12
},
'public', 'public',
'geo' 'geo'
) )
relatedOffers.value = offersData?.offersBySupplierProduct || []
// Load hubs for each offer and aggregate (limit to 12) relatedOffers.value = offersData?.nearestOffers || []
// Load hubs near each offer and aggregate (limit to 12)
const allHubs = new Map<string, any>() const allHubs = new Map<string, any>()
for (const offer of relatedOffers.value.slice(0, 3)) { for (const offer of relatedOffers.value.slice(0, 3)) {
// Check first 3 offers // Check first 3 offers
if (!offer.latitude || !offer.longitude) continue
try { try {
const hubsData = await execute( const hubsData = await execute(
GetHubsNearOfferDocument, NearestHubsDocument,
{ offerUuid: offer.uuid, limit: 5 }, {
lat: offer.latitude,
lon: offer.longitude,
radius: 1000,
limit: 5
},
'public', 'public',
'geo' 'geo'
) )
hubsData?.hubsNearOffer?.forEach((hub: any) => { hubsData?.nearestHubs?.forEach((hub: any) => {
if (!allHubs.has(hub.uuid)) { if (!allHubs.has(hub.uuid)) {
allHubs.set(hub.uuid, hub) allHubs.set(hub.uuid, hub)
} }
}) })
} catch (e) { } catch (e) {
// Hubs might not exist for this offer console.warn('Error loading hubs for offer:', offer.uuid, e)
} }
if (allHubs.size >= 12) break if (allHubs.size >= 12) break

View File

@@ -1,8 +1,11 @@
import { import {
GetProductsDocument, GetProductsDocument,
GetProductsBySupplierDocument, GetNodeDocument,
GetProductsNearHubDocument NearestOffersDocument
} from '~/composables/graphql/public/geo-generated' } from '~/composables/graphql/public/geo-generated'
import {
GetSupplierProfileDocument
} from '~/composables/graphql/public/exchange-generated'
// Shared state // Shared state
const items = ref<any[]>([]) const items = ref<any[]>([])
@@ -27,23 +30,89 @@ export function useCatalogProducts() {
let data let data
if (filterSupplierUuid.value) { if (filterSupplierUuid.value) {
// Products from specific supplier // Products from specific supplier - get supplier coordinates first
data = await execute( const supplierData = await execute(
GetProductsBySupplierDocument, GetSupplierProfileDocument,
{ supplierUuid: filterSupplierUuid.value }, { supplierUuid: filterSupplierUuid.value },
'public', 'public',
'geo' 'exchange'
) )
items.value = data?.productsBySupplier || [] const supplier = supplierData?.getSupplierProfile
} else if (filterHubUuid.value) {
// Products near hub if (!supplier?.latitude || !supplier?.longitude) {
data = await execute( console.warn('Supplier has no coordinates')
GetProductsNearHubDocument, items.value = []
{ hubUuid: filterHubUuid.value }, } else {
// Get offers near supplier and group by product
const offersData = await execute(
NearestOffersDocument,
{
lat: supplier.latitude,
lon: supplier.longitude,
radius: 500
},
'public', 'public',
'geo' 'geo'
) )
items.value = data?.productsNearHub || []
// Group offers by product
const productsMap = new Map<string, any>()
offersData?.nearestOffers?.forEach((offer: any) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
})
}
productsMap.get(offer.productUuid)!.offersCount++
}
})
items.value = Array.from(productsMap.values())
}
} else if (filterHubUuid.value) {
// Products near hub - get hub coordinates first
const hubData = await execute(
GetNodeDocument,
{ uuid: filterHubUuid.value },
'public',
'geo'
)
const hub = hubData?.node
if (!hub?.latitude || !hub?.longitude) {
console.warn('Hub has no coordinates')
items.value = []
} else {
// Get offers near hub and group by product
const offersData = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
radius: 500
},
'public',
'geo'
)
// Group offers by product
const productsMap = new Map<string, any>()
offersData?.nearestOffers?.forEach((offer: any) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 0
})
}
productsMap.get(offer.productUuid)!.offersCount++
}
})
items.value = Array.from(productsMap.values())
}
} else { } else {
// All products // All products
data = await execute( data = await execute(

View File

@@ -1,5 +1,5 @@
import { GetSupplierProfilesDocument } from '~/composables/graphql/public/exchange-generated' import { GetSupplierProfilesDocument } from '~/composables/graphql/public/exchange-generated'
import { GetSuppliersForProductDocument } from '~/composables/graphql/public/geo-generated' import { NearestSuppliersDocument } from '~/composables/graphql/public/geo-generated'
const PAGE_SIZE = 24 const PAGE_SIZE = 24
@@ -24,29 +24,22 @@ export function useCatalogSuppliers() {
const fetchPage = async (offset: number, replace = false) => { const fetchPage = async (offset: number, replace = false) => {
if (replace) isLoading.value = true if (replace) isLoading.value = true
try { try {
// If filtering by product, use specialized query // If filtering by product, use nearestSuppliers with global search
// (center point 0,0 with very large radius to cover entire globe)
if (filterProductUuid.value) { if (filterProductUuid.value) {
const data = await execute( const data = await execute(
GetSuppliersForProductDocument, NearestSuppliersDocument,
{ productUuid: filterProductUuid.value }, {
lat: 0,
lon: 0,
radius: 20000, // 20000 km radius covers entire Earth
productUuid: filterProductUuid.value,
limit: 500 // Increased limit for global search
},
'public', 'public',
'geo' 'geo'
) )
const supplierUuids = (data?.suppliersForProduct || []).map(s => s?.uuid).filter(Boolean) items.value = data?.nearestSuppliers || []
// Fetch full profiles for these suppliers
if (supplierUuids.length > 0) {
const profilesData = await execute(
GetSupplierProfilesDocument,
{ limit: supplierUuids.length, offset: 0 },
'public',
'exchange'
)
// Filter to only include suppliers that match the product filter
const allProfiles = profilesData?.getSupplierProfiles || []
items.value = allProfiles.filter(p => supplierUuids.includes(p.uuid))
} else {
items.value = []
}
total.value = items.value.length total.value = items.value.length
isInitialized.value = true isInitialized.value = true
return return

View File

@@ -43,7 +43,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GetNodeConnectionsDocument, GetProductsNearHubDocument } from '~/composables/graphql/public/geo-generated' import { GetNodeConnectionsDocument, NearestOffersDocument } from '~/composables/graphql/public/geo-generated'
definePageMeta({ definePageMeta({
layout: 'topnav' layout: 'topnav'
@@ -122,17 +122,45 @@ const getMockPriceHistory = (uuid: string): number[] => {
// Initial load // Initial load
try { try {
const [{ data: connectionsData }, { data: productsData }] = await Promise.all([ // First load hub connections to get coordinates
useServerQuery('hub-connections', GetNodeConnectionsDocument, { uuid: hubId.value }, 'public', 'geo'), const { data: connectionsData } = await useServerQuery(
useServerQuery('products-near-hub', GetProductsNearHubDocument, { hubUuid: hubId.value, radiusKm: 500 }, 'public', 'geo') 'hub-connections',
]) GetNodeConnectionsDocument,
{ uuid: hubId.value },
'public',
'geo'
)
hub.value = connectionsData.value?.nodeConnections?.hub || null hub.value = connectionsData.value?.nodeConnections?.hub || null
// Get products near this hub (from geo) // Load offers near hub and group by product
products.value = (productsData.value?.productsNearHub || []) if (hub.value?.latitude && hub.value?.longitude) {
.filter((p): p is { uuid: string; name?: string | null } => p !== null && !!p.uuid) const { data: offersData } = await useServerQuery(
.map(p => ({ uuid: p.uuid!, name: p.name || '' })) 'offers-near-hub',
NearestOffersDocument,
{
lat: hub.value.latitude,
lon: hub.value.longitude,
radius: 500
},
'public',
'geo'
)
// Group offers by product
const productsMap = new Map<string, { uuid: string; name: string }>()
offersData.value?.nearestOffers?.forEach((offer: any) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName || ''
})
}
}
})
products.value = Array.from(productsMap.values())
}
} catch (error) { } catch (error) {
console.error('Error loading hub:', error) console.error('Error loading hub:', error)
} finally { } finally {