mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
606 lines
18 KiB
Go
606 lines
18 KiB
Go
package matching
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"bugulma/backend/internal/analysis/regulatory"
|
|
"bugulma/backend/internal/analysis/risk"
|
|
"bugulma/backend/internal/analysis/transport"
|
|
"bugulma/backend/internal/domain"
|
|
"bugulma/backend/internal/geospatial"
|
|
"bugulma/backend/internal/matching/engine"
|
|
"bugulma/backend/internal/matching/manager"
|
|
"bugulma/backend/internal/matching/plugins"
|
|
)
|
|
|
|
// Service orchestrates all matching functionality
|
|
type Service struct {
|
|
engine *engine.Engine
|
|
manager *manager.Manager
|
|
pluginMgr *plugins.Manager
|
|
matchRepo domain.MatchRepository
|
|
|
|
// Dependencies for data access
|
|
resourceFlowRepo domain.ResourceFlowRepository
|
|
siteRepo domain.SiteRepository
|
|
orgRepo domain.OrganizationRepository
|
|
productRepo domain.ProductRepository
|
|
serviceRepo domain.ServiceRepository
|
|
communityListingRepo domain.CommunityListingRepository
|
|
eventBus domain.EventBus
|
|
|
|
// Geospatial calculator
|
|
geoCalc geospatial.Calculator
|
|
|
|
// Discovery matcher for products/services
|
|
discoveryMatcher *DiscoveryMatcher
|
|
}
|
|
|
|
// EventBus is now defined in the domain package
|
|
|
|
// NewService creates a new matching service
|
|
func NewService(
|
|
matchRepo domain.MatchRepository,
|
|
negotiationRepo domain.NegotiationHistoryRepository,
|
|
resourceFlowRepo domain.ResourceFlowRepository,
|
|
siteRepo domain.SiteRepository,
|
|
orgRepo domain.OrganizationRepository,
|
|
productRepo domain.ProductRepository,
|
|
serviceRepo domain.ServiceRepository,
|
|
communityListingRepo domain.CommunityListingRepository,
|
|
riskSvc *risk.Service,
|
|
transportSvc *transport.Service,
|
|
regulatorySvc *regulatory.Service,
|
|
eventBus domain.EventBus,
|
|
) *Service {
|
|
|
|
// Create engine with analysis services
|
|
eng := engine.NewEngine(riskSvc, transportSvc, regulatorySvc)
|
|
|
|
// Create manager for lifecycle operations
|
|
mgr := manager.NewManager(matchRepo, negotiationRepo, eventBus)
|
|
|
|
// Create plugin registry and register default plugins
|
|
pluginRegistry := plugins.NewRegistry()
|
|
if err := plugins.RegisterDefaultPlugins(pluginRegistry); err != nil {
|
|
// Log error but continue - plugins are optional enhancement
|
|
// In production, this would be logged properly
|
|
}
|
|
|
|
// Create plugin manager
|
|
pluginMgr := plugins.NewManager(pluginRegistry, eng)
|
|
|
|
// Create geospatial calculator
|
|
geoCalc := geospatial.NewCalculatorWithDefaults()
|
|
|
|
// Create discovery matcher for products/services
|
|
discoveryMatcher := NewDiscoveryMatcher()
|
|
|
|
return &Service{
|
|
engine: eng,
|
|
manager: mgr,
|
|
pluginMgr: pluginMgr,
|
|
matchRepo: matchRepo,
|
|
resourceFlowRepo: resourceFlowRepo,
|
|
siteRepo: siteRepo,
|
|
orgRepo: orgRepo,
|
|
productRepo: productRepo,
|
|
serviceRepo: serviceRepo,
|
|
communityListingRepo: communityListingRepo,
|
|
eventBus: eventBus,
|
|
geoCalc: geoCalc,
|
|
discoveryMatcher: discoveryMatcher,
|
|
}
|
|
}
|
|
|
|
// FindMatches finds potential matches based on criteria
|
|
func (s *Service) FindMatches(ctx context.Context, criteria Criteria) ([]*engine.Candidate, error) {
|
|
// Find potential source and target flows
|
|
sourceFlows, err := s.findSourceFlows(ctx, criteria)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
targetFlows, err := s.findTargetFlows(ctx, criteria)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Use engine to find initial matches
|
|
candidates, err := s.engine.FindMatches(criteria, sourceFlows, targetFlows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Enhance candidates with plugin logic
|
|
var enhancedCandidates []*engine.Candidate
|
|
for _, candidate := range candidates {
|
|
// Calculate accurate distance using geospatial calculator
|
|
sourceSite, err := s.siteRepo.GetByID(ctx, candidate.SourceFlow.SiteID)
|
|
if err != nil {
|
|
continue // Skip if we can't get site info
|
|
}
|
|
targetSite, err := s.siteRepo.GetByID(ctx, candidate.TargetFlow.SiteID)
|
|
if err != nil {
|
|
continue // Skip if we can't get site info
|
|
}
|
|
|
|
distanceResult, err := s.geoCalc.CalculateDistance(
|
|
geospatial.Point{Latitude: sourceSite.Latitude, Longitude: sourceSite.Longitude},
|
|
geospatial.Point{Latitude: targetSite.Latitude, Longitude: targetSite.Longitude},
|
|
)
|
|
if err != nil {
|
|
continue // Skip if distance calculation fails
|
|
}
|
|
|
|
candidate.DistanceKm = distanceResult.DistanceKm
|
|
|
|
// Enhance candidate with plugin logic
|
|
if err := s.pluginMgr.EnhanceCandidate(candidate); err != nil {
|
|
// Log error but continue with basic candidate
|
|
continue
|
|
}
|
|
|
|
// Validate candidate with plugins
|
|
if valid, _ := s.pluginMgr.ValidateWithPlugins(candidate); valid {
|
|
enhancedCandidates = append(enhancedCandidates, candidate)
|
|
}
|
|
}
|
|
|
|
// Re-rank enhanced candidates
|
|
enhancedCandidates = s.rerankCandidates(enhancedCandidates)
|
|
|
|
return enhancedCandidates, nil
|
|
}
|
|
|
|
// CreateMatch creates a new match from a candidate
|
|
func (s *Service) CreateMatch(ctx context.Context, candidate *engine.Candidate, creatorID string) (*domain.Match, error) {
|
|
return s.manager.CreateMatch(ctx, candidate, creatorID)
|
|
}
|
|
|
|
// UpdateMatchStatus updates match status
|
|
func (s *Service) UpdateMatchStatus(ctx context.Context, matchID string, newStatus domain.MatchStatus, actorID string, notes string) error {
|
|
return s.manager.UpdateMatchStatus(ctx, matchID, newStatus, actorID, notes)
|
|
}
|
|
|
|
// UpdateMatch updates match details
|
|
func (s *Service) UpdateMatch(ctx context.Context, match *domain.Match) error {
|
|
return s.manager.UpdateMatch(ctx, match)
|
|
}
|
|
|
|
// DeleteMatch deletes a match
|
|
func (s *Service) DeleteMatch(ctx context.Context, matchID string, actorID string) error {
|
|
return s.manager.DeleteMatch(ctx, matchID, actorID)
|
|
}
|
|
|
|
// GetMatchByID retrieves a match by ID
|
|
func (s *Service) GetMatchByID(ctx context.Context, matchID string) (*domain.Match, error) {
|
|
return s.matchRepo.GetByID(ctx, matchID)
|
|
}
|
|
|
|
// GetTopMatches retrieves top matches
|
|
func (s *Service) GetTopMatches(ctx context.Context, limit int) ([]*domain.Match, error) {
|
|
return s.matchRepo.GetTopMatches(ctx, limit)
|
|
}
|
|
|
|
// GetNegotiationHistory retrieves negotiation history for a match
|
|
func (s *Service) GetNegotiationHistory(ctx context.Context, matchID string) ([]*domain.NegotiationHistoryEntry, error) {
|
|
return s.manager.GetNegotiationHistory(ctx, matchID)
|
|
}
|
|
|
|
// GetResourceFlowByID retrieves a resource flow by ID
|
|
func (s *Service) GetResourceFlowByID(ctx context.Context, id string) (*domain.ResourceFlow, error) {
|
|
return s.resourceFlowRepo.GetByID(ctx, id)
|
|
}
|
|
|
|
// GetSiteByID retrieves a site by ID
|
|
func (s *Service) GetSiteByID(ctx context.Context, id string) (*domain.Site, error) {
|
|
return s.siteRepo.GetByID(ctx, id)
|
|
}
|
|
|
|
// CalculateDistance calculates distance between two sites
|
|
func (s *Service) CalculateDistance(lat1, lon1, lat2, lon2 float64) float64 {
|
|
result, err := s.geoCalc.CalculateDistance(
|
|
geospatial.Point{Latitude: lat1, Longitude: lon1},
|
|
geospatial.Point{Latitude: lat2, Longitude: lon2},
|
|
)
|
|
if err != nil {
|
|
// Fallback to 0 if calculation fails (shouldn't happen with valid coordinates)
|
|
return 0
|
|
}
|
|
return result.DistanceKm
|
|
}
|
|
|
|
// findSourceFlows finds resource flows that can be sources (outputs)
|
|
func (s *Service) findSourceFlows(ctx context.Context, criteria Criteria) ([]*domain.ResourceFlow, error) {
|
|
// For output matching, we look for flows with opposite direction
|
|
oppositeDirection := s.getOppositeDirection(criteria.ResourceType)
|
|
|
|
flows, err := s.resourceFlowRepo.GetByTypeAndDirection(ctx, criteria.ResourceType, oppositeDirection)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Filter by organization if specified
|
|
if criteria.OrganizationID != "" {
|
|
var filtered []*domain.ResourceFlow
|
|
for _, flow := range flows {
|
|
if flow.OrganizationID == criteria.OrganizationID {
|
|
filtered = append(filtered, flow)
|
|
}
|
|
}
|
|
flows = filtered
|
|
}
|
|
|
|
return flows, nil
|
|
}
|
|
|
|
// findTargetFlows finds resource flows that can be targets (inputs)
|
|
func (s *Service) findTargetFlows(ctx context.Context, criteria Criteria) ([]*domain.ResourceFlow, error) {
|
|
// For input matching, we look for flows in the specified direction
|
|
flows, err := s.resourceFlowRepo.GetByTypeAndDirection(ctx, criteria.ResourceType, criteria.Direction)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Apply geographic filtering if site specified
|
|
if criteria.SiteID != "" {
|
|
flows = s.filterBySiteProximity(ctx, flows, criteria.SiteID, criteria.MaxDistanceKm)
|
|
}
|
|
|
|
return flows, nil
|
|
}
|
|
|
|
// filterBySiteProximity filters flows by distance from a reference site
|
|
func (s *Service) filterBySiteProximity(ctx context.Context, flows []*domain.ResourceFlow, siteID string, maxDistance float64) []*domain.ResourceFlow {
|
|
if maxDistance <= 0 {
|
|
return flows
|
|
}
|
|
|
|
refSite, err := s.siteRepo.GetByID(ctx, siteID)
|
|
if err != nil {
|
|
return flows // Return all if we can't get reference site
|
|
}
|
|
|
|
var filtered []*domain.ResourceFlow
|
|
for _, flow := range flows {
|
|
flowSite, err := s.siteRepo.GetByID(ctx, flow.SiteID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
result, err := s.geoCalc.CalculateDistance(
|
|
geospatial.Point{Latitude: refSite.Latitude, Longitude: refSite.Longitude},
|
|
geospatial.Point{Latitude: flowSite.Latitude, Longitude: flowSite.Longitude},
|
|
)
|
|
if err != nil {
|
|
continue // Skip if distance calculation fails
|
|
}
|
|
distance := result.DistanceKm
|
|
|
|
if distance <= maxDistance {
|
|
filtered = append(filtered, flow)
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// getOppositeDirection returns the opposite resource direction
|
|
func (s *Service) getOppositeDirection(resourceType domain.ResourceType) domain.ResourceDirection {
|
|
return domain.DirectionOutput // Simplified - would depend on matching strategy
|
|
}
|
|
|
|
// rerankCandidates sorts candidates by enhanced overall score
|
|
func (s *Service) rerankCandidates(candidates []*engine.Candidate) []*engine.Candidate {
|
|
// Simple sort by overall score (descending)
|
|
for i := 0; i < len(candidates); i++ {
|
|
for j := i + 1; j < len(candidates); j++ {
|
|
if candidates[i].OverallScore < candidates[j].OverallScore {
|
|
candidates[i], candidates[j] = candidates[j], candidates[i]
|
|
}
|
|
}
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
// Import Criteria from engine package
|
|
type Criteria = engine.Criteria
|
|
|
|
// FindProductMatches finds products matching the discovery query
|
|
func (s *Service) FindProductMatches(ctx context.Context, query DiscoveryQuery) ([]*DiscoveryMatch, error) {
|
|
if s.productRepo == nil {
|
|
return nil, fmt.Errorf("product repository not available")
|
|
}
|
|
|
|
// Search products based on query
|
|
var products []*domain.Product
|
|
var err error
|
|
|
|
if query.Location != nil && query.RadiusKm > 0 {
|
|
products, err = s.productRepo.GetNearby(ctx, query.Location.Latitude, query.Location.Longitude, query.RadiusKm)
|
|
} else {
|
|
products, err = s.productRepo.GetAll(ctx)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch products: %w", err)
|
|
}
|
|
|
|
// Score and rank matches
|
|
var matches []*DiscoveryMatch
|
|
for _, product := range products {
|
|
// Get organization and site
|
|
var org *domain.Organization
|
|
var site *domain.Site
|
|
if product.OrganizationID != "" {
|
|
org, _ = s.orgRepo.GetByID(ctx, product.OrganizationID)
|
|
}
|
|
if product.SiteID != nil {
|
|
site, _ = s.siteRepo.GetByID(ctx, *product.SiteID)
|
|
}
|
|
|
|
match, err := s.discoveryMatcher.ScoreProductMatch(product, query, org, site)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Apply filters
|
|
if query.AvailabilityStatus != "" && product.AvailabilityStatus != query.AvailabilityStatus {
|
|
continue
|
|
}
|
|
if len(query.Categories) > 0 {
|
|
categoryMatched := false
|
|
for _, cat := range query.Categories {
|
|
if strings.EqualFold(cat, string(product.Category)) {
|
|
categoryMatched = true
|
|
break
|
|
}
|
|
}
|
|
if !categoryMatched {
|
|
continue
|
|
}
|
|
}
|
|
|
|
matches = append(matches, match)
|
|
}
|
|
|
|
// Sort by relevance score
|
|
for i := 0; i < len(matches); i++ {
|
|
for j := i + 1; j < len(matches); j++ {
|
|
if matches[i].RelevanceScore < matches[j].RelevanceScore {
|
|
matches[i], matches[j] = matches[j], matches[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply pagination
|
|
start := query.Offset
|
|
end := start + query.Limit
|
|
if start >= len(matches) {
|
|
return []*DiscoveryMatch{}, nil
|
|
}
|
|
if end > len(matches) {
|
|
end = len(matches)
|
|
}
|
|
|
|
return matches[start:end], nil
|
|
}
|
|
|
|
// FindServiceMatches finds services matching the discovery query
|
|
func (s *Service) FindServiceMatches(ctx context.Context, query DiscoveryQuery) ([]*DiscoveryMatch, error) {
|
|
if s.serviceRepo == nil {
|
|
return nil, fmt.Errorf("service repository not available")
|
|
}
|
|
|
|
// Search services based on query
|
|
var services []*domain.Service
|
|
var err error
|
|
|
|
if query.Location != nil && query.RadiusKm > 0 {
|
|
services, err = s.serviceRepo.GetNearby(ctx, query.Location.Latitude, query.Location.Longitude, query.RadiusKm)
|
|
} else {
|
|
services, err = s.serviceRepo.GetAll(ctx)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch services: %w", err)
|
|
}
|
|
|
|
// Score and rank matches
|
|
var matches []*DiscoveryMatch
|
|
for _, service := range services {
|
|
// Get organization and site
|
|
var org *domain.Organization
|
|
var site *domain.Site
|
|
if service.OrganizationID != "" {
|
|
org, _ = s.orgRepo.GetByID(ctx, service.OrganizationID)
|
|
}
|
|
if service.SiteID != nil {
|
|
site, _ = s.siteRepo.GetByID(ctx, *service.SiteID)
|
|
}
|
|
|
|
match, err := s.discoveryMatcher.ScoreServiceMatch(service, query, org, site)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Apply filters
|
|
if query.AvailabilityStatus != "" && service.AvailabilityStatus != query.AvailabilityStatus {
|
|
continue
|
|
}
|
|
|
|
matches = append(matches, match)
|
|
}
|
|
|
|
// Sort by relevance score
|
|
for i := 0; i < len(matches); i++ {
|
|
for j := i + 1; j < len(matches); j++ {
|
|
if matches[i].RelevanceScore < matches[j].RelevanceScore {
|
|
matches[i], matches[j] = matches[j], matches[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply pagination
|
|
start := query.Offset
|
|
end := start + query.Limit
|
|
if start >= len(matches) {
|
|
return []*DiscoveryMatch{}, nil
|
|
}
|
|
if end > len(matches) {
|
|
end = len(matches)
|
|
}
|
|
|
|
return matches[start:end], nil
|
|
}
|
|
|
|
// UniversalSearch performs a unified search across resources, products, services, and community listings
|
|
func (s *Service) UniversalSearch(ctx context.Context, query DiscoveryQuery) (*UniversalSearchResult, error) {
|
|
result := &UniversalSearchResult{
|
|
Query: query,
|
|
}
|
|
|
|
// Search products (soft match)
|
|
if productMatches, err := s.FindProductMatches(ctx, query); err == nil {
|
|
result.ProductMatches = productMatches
|
|
}
|
|
|
|
// Search services (soft match)
|
|
if serviceMatches, err := s.FindServiceMatches(ctx, query); err == nil {
|
|
result.ServiceMatches = serviceMatches
|
|
}
|
|
|
|
// Search community listings (soft match)
|
|
if s.communityListingRepo != nil {
|
|
var listings []*domain.CommunityListing
|
|
var err error
|
|
if query.Location != nil && query.RadiusKm > 0 {
|
|
listings, err = s.communityListingRepo.GetNearby(ctx, query.Location.Latitude, query.Location.Longitude, query.RadiusKm)
|
|
} else {
|
|
listings, err = s.communityListingRepo.GetAll(ctx)
|
|
}
|
|
if err == nil {
|
|
for _, listing := range listings {
|
|
match, err := s.discoveryMatcher.ScoreCommunityListingMatch(listing, query)
|
|
if err == nil {
|
|
result.CommunityMatches = append(result.CommunityMatches, match)
|
|
}
|
|
}
|
|
// Sort community matches
|
|
for i := 0; i < len(result.CommunityMatches); i++ {
|
|
for j := i + 1; j < len(result.CommunityMatches); j++ {
|
|
if result.CommunityMatches[i].RelevanceScore < result.CommunityMatches[j].RelevanceScore {
|
|
result.CommunityMatches[i], result.CommunityMatches[j] = result.CommunityMatches[j], result.CommunityMatches[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Note: Resource flow matches (hard match) would be handled separately via FindMatches
|
|
// This is the "soft match" layer as per concept's layered architecture
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// UniversalSearchResult contains results from universal search
|
|
type UniversalSearchResult struct {
|
|
Query DiscoveryQuery `json:"query"`
|
|
ProductMatches []*DiscoveryMatch `json:"product_matches"`
|
|
ServiceMatches []*DiscoveryMatch `json:"service_matches"`
|
|
CommunityMatches []*DiscoveryMatch `json:"community_matches"`
|
|
// ResourceMatches would be added via separate FindMatches call (hard match)
|
|
}
|
|
|
|
// GetProductsByOrganization gets products for a specific organization (efficient method)
|
|
func (s *Service) GetProductsByOrganization(ctx context.Context, organizationID string) ([]*domain.Product, error) {
|
|
if s.productRepo == nil {
|
|
return nil, fmt.Errorf("product repository not available")
|
|
}
|
|
return s.productRepo.GetByOrganization(ctx, organizationID)
|
|
}
|
|
|
|
// GetServicesByOrganization gets services for a specific organization (efficient method)
|
|
func (s *Service) GetServicesByOrganization(ctx context.Context, organizationID string) ([]*domain.Service, error) {
|
|
if s.serviceRepo == nil {
|
|
return nil, fmt.Errorf("service repository not available")
|
|
}
|
|
return s.serviceRepo.GetByOrganization(ctx, organizationID)
|
|
}
|
|
|
|
// CreateProduct creates a new product with site linking support
|
|
func (s *Service) CreateProduct(ctx context.Context, product *domain.Product) error {
|
|
if s.productRepo == nil {
|
|
return fmt.Errorf("product repository not available")
|
|
}
|
|
|
|
// If SiteID is provided, populate location from site
|
|
if product.SiteID != nil && *product.SiteID != "" {
|
|
site, err := s.siteRepo.GetByID(ctx, *product.SiteID)
|
|
if err == nil && site != nil {
|
|
// Set location from site coordinates
|
|
product.Location = domain.Point{
|
|
Latitude: site.Latitude,
|
|
Longitude: site.Longitude,
|
|
Valid: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := s.productRepo.Create(ctx, product); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Sync to graph database if graph sync service is available
|
|
// Note: This would require passing graph sync service to matching service
|
|
// For now, we'll rely on event-driven sync
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateService creates a new service with site linking support
|
|
func (s *Service) CreateService(ctx context.Context, service *domain.Service) error {
|
|
if s.serviceRepo == nil {
|
|
return fmt.Errorf("service repository not available")
|
|
}
|
|
|
|
// If SiteID is provided, populate location from site
|
|
if service.SiteID != nil && *service.SiteID != "" {
|
|
site, err := s.siteRepo.GetByID(ctx, *service.SiteID)
|
|
if err == nil && site != nil {
|
|
// Set location from site coordinates
|
|
service.ServiceLocation = domain.Point{
|
|
Latitude: site.Latitude,
|
|
Longitude: site.Longitude,
|
|
Valid: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := s.serviceRepo.Create(ctx, service); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateCommunityListing creates a new community listing
|
|
func (s *Service) CreateCommunityListing(ctx context.Context, listing *domain.CommunityListing) error {
|
|
if s.communityListingRepo == nil {
|
|
return fmt.Errorf("community listing repository not available")
|
|
}
|
|
|
|
// Validate the listing
|
|
if err := listing.Validate(); err != nil {
|
|
return fmt.Errorf("invalid listing: %w", err)
|
|
}
|
|
|
|
// Create the listing
|
|
if err := s.communityListingRepo.Create(ctx, listing); err != nil {
|
|
return fmt.Errorf("failed to create community listing: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|