package handlers import ( "net/http" "sort" "strconv" "strings" "time" "github.com/gin-gonic/gin" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services/alerts" "git.mchus.pro/mchus/quoteforge/internal/services/pricing" "gorm.io/gorm" ) // calculateMedian returns the median of a sorted slice of prices func calculateMedian(prices []float64) float64 { if len(prices) == 0 { return 0 } sort.Float64s(prices) n := len(prices) if n%2 == 0 { return (prices[n/2-1] + prices[n/2]) / 2 } return prices[n/2] } // calculateAverage returns the arithmetic mean of prices func calculateAverage(prices []float64) float64 { if len(prices) == 0 { return 0 } var sum float64 for _, p := range prices { sum += p } return sum / float64(len(prices)) } type PricingHandler struct { db *gorm.DB pricingService *pricing.Service alertService *alerts.Service componentRepo *repository.ComponentRepository priceRepo *repository.PriceRepository statsRepo *repository.StatsRepository } func NewPricingHandler( db *gorm.DB, pricingService *pricing.Service, alertService *alerts.Service, componentRepo *repository.ComponentRepository, priceRepo *repository.PriceRepository, statsRepo *repository.StatsRepository, ) *PricingHandler { return &PricingHandler{ db: db, pricingService: pricingService, alertService: alertService, componentRepo: componentRepo, priceRepo: priceRepo, statsRepo: statsRepo, } } func (h *PricingHandler) GetStats(c *gin.Context) { // Check if we're in offline mode if h.statsRepo == nil || h.alertService == nil { c.JSON(http.StatusOK, gin.H{ "new_alerts_count": 0, "top_components": []interface{}{}, "trending_components": []interface{}{}, "offline": true, }) return } newAlerts, _ := h.alertService.GetNewAlertsCount() topComponents, _ := h.statsRepo.GetTopComponents(10) trendingComponents, _ := h.statsRepo.GetTrendingComponents(10) c.JSON(http.StatusOK, gin.H{ "new_alerts_count": newAlerts, "top_components": topComponents, "trending_components": trendingComponents, }) } type ComponentWithCount struct { models.LotMetadata QuoteCount int64 `json:"quote_count"` UsedInMeta []string `json:"used_in_meta,omitempty"` // List of meta-articles that use this component } func (h *PricingHandler) ListComponents(c *gin.Context) { // Check if we're in offline mode if h.componentRepo == nil { c.JSON(http.StatusOK, gin.H{ "components": []ComponentWithCount{}, "total": 0, "page": 1, "per_page": 20, "offline": true, "message": "Управление ценами доступно только в онлайн режиме", }) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) filter := repository.ComponentFilter{ Category: c.Query("category"), Search: c.Query("search"), SortField: c.Query("sort"), SortDir: c.Query("dir"), } if page < 1 { page = 1 } if perPage < 1 || perPage > 100 { perPage = 20 } offset := (page - 1) * perPage components, total, err := h.componentRepo.List(filter, offset, perPage) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Get quote counts lotNames := make([]string, len(components)) for i, comp := range components { lotNames[i] = comp.LotName } counts, _ := h.priceRepo.GetQuoteCounts(lotNames) // Get meta usage information metaUsage := h.getMetaUsageMap(lotNames) // Combine components with counts result := make([]ComponentWithCount, len(components)) for i, comp := range components { result[i] = ComponentWithCount{ LotMetadata: comp, QuoteCount: counts[comp.LotName], UsedInMeta: metaUsage[comp.LotName], } } c.JSON(http.StatusOK, gin.H{ "components": result, "total": total, "page": page, "per_page": perPage, }) } // getMetaUsageMap returns a map of lot_name -> list of meta-articles that use this component func (h *PricingHandler) getMetaUsageMap(lotNames []string) map[string][]string { result := make(map[string][]string) // Get all components with meta_prices var metaComponents []models.LotMetadata h.db.Where("meta_prices IS NOT NULL AND meta_prices != ''").Find(&metaComponents) // Build reverse lookup: which components are used in which meta-articles for _, meta := range metaComponents { sources := strings.Split(meta.MetaPrices, ",") for _, source := range sources { source = strings.TrimSpace(source) if source == "" { continue } // Handle wildcard patterns if strings.HasSuffix(source, "*") { prefix := strings.TrimSuffix(source, "*") for _, lotName := range lotNames { if strings.HasPrefix(lotName, prefix) && lotName != meta.LotName { result[lotName] = append(result[lotName], meta.LotName) } } } else { // Direct match for _, lotName := range lotNames { if lotName == source && lotName != meta.LotName { result[lotName] = append(result[lotName], meta.LotName) } } } } } return result } // expandMetaPrices expands meta_prices string to list of actual lot names func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []string { sources := strings.Split(metaPrices, ",") var result []string seen := make(map[string]bool) for _, source := range sources { source = strings.TrimSpace(source) if source == "" { continue } if strings.HasSuffix(source, "*") { // Wildcard pattern - find matching lots prefix := strings.TrimSuffix(source, "*") var matchingLots []string h.db.Model(&models.LotMetadata{}). Where("lot_name LIKE ? AND lot_name != ?", prefix+"%", excludeLot). Pluck("lot_name", &matchingLots) for _, lot := range matchingLots { if !seen[lot] { result = append(result, lot) seen[lot] = true } } } else if source != excludeLot && !seen[source] { result = append(result, source) seen[source] = true } } return result } func (h *PricingHandler) GetComponentPricing(c *gin.Context) { // Check if we're in offline mode if h.componentRepo == nil || h.pricingService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Управление ценами доступно только в онлайн режиме", "offline": true, }) return } lotName := c.Param("lot_name") component, err := h.componentRepo.GetByLotName(lotName) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "component not found"}) return } stats, err := h.pricingService.GetPriceStats(lotName, 0) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "component": component, "price_stats": stats, }) } type UpdatePriceRequest struct { LotName string `json:"lot_name" binding:"required"` Method models.PriceMethod `json:"method"` PeriodDays int `json:"period_days"` Coefficient float64 `json:"coefficient"` ManualPrice *float64 `json:"manual_price"` ClearManual bool `json:"clear_manual"` MetaEnabled bool `json:"meta_enabled"` MetaPrices string `json:"meta_prices"` MetaMethod string `json:"meta_method"` MetaPeriod int `json:"meta_period"` IsHidden bool `json:"is_hidden"` } func (h *PricingHandler) UpdatePrice(c *gin.Context) { // Check if we're in offline mode if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Обновление цен доступно только в онлайн режиме", "offline": true, }) return } var req UpdatePriceRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } updates := map[string]interface{}{} // Update method if specified if req.Method != "" { updates["price_method"] = req.Method } // Update period days if req.PeriodDays >= 0 { updates["price_period_days"] = req.PeriodDays } // Update coefficient updates["price_coefficient"] = req.Coefficient // Handle meta prices if req.MetaEnabled && req.MetaPrices != "" { updates["meta_prices"] = req.MetaPrices } else { updates["meta_prices"] = "" } // Handle hidden flag updates["is_hidden"] = req.IsHidden // Handle manual price if req.ClearManual { updates["manual_price"] = nil } else if req.ManualPrice != nil { updates["manual_price"] = *req.ManualPrice // Also update current price immediately when setting manual updates["current_price"] = *req.ManualPrice updates["price_updated_at"] = time.Now() } err := h.db.Model(&models.LotMetadata{}). Where("lot_name = ?", req.LotName). Updates(updates).Error if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Recalculate price if not using manual price if req.ManualPrice == nil { h.recalculateSinglePrice(req.LotName) } // Get updated component to return new price var comp models.LotMetadata h.db.Where("lot_name = ?", req.LotName).First(&comp) c.JSON(http.StatusOK, gin.H{ "message": "price updated", "current_price": comp.CurrentPrice, }) } func (h *PricingHandler) recalculateSinglePrice(lotName string) { var comp models.LotMetadata if err := h.db.Where("lot_name = ?", lotName).First(&comp).Error; err != nil { return } // Skip if manual price is set if comp.ManualPrice != nil && *comp.ManualPrice > 0 { return } periodDays := comp.PricePeriodDays method := comp.PriceMethod if method == "" { method = models.PriceMethodMedian } // Determine which lot names to use for price calculation lotNames := []string{lotName} if comp.MetaPrices != "" { lotNames = h.expandMetaPrices(comp.MetaPrices, lotName) } // Get prices based on period from all relevant lots var prices []float64 for _, ln := range lotNames { var lotPrices []float64 if strings.HasSuffix(ln, "*") { pattern := strings.TrimSuffix(ln, "*") + "%" if periodDays > 0 { h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, pattern, periodDays).Pluck("price", &lotPrices) } else { h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices) } } else { if periodDays > 0 { h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, ln, periodDays).Pluck("price", &lotPrices) } else { h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices) } } prices = append(prices, lotPrices...) } // If no prices in period, try all time if len(prices) == 0 && periodDays > 0 { for _, ln := range lotNames { var lotPrices []float64 if strings.HasSuffix(ln, "*") { pattern := strings.TrimSuffix(ln, "*") + "%" h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices) } else { h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices) } prices = append(prices, lotPrices...) } } if len(prices) == 0 { return } // Calculate price based on method sortFloat64s(prices) var finalPrice float64 switch method { case models.PriceMethodMedian: finalPrice = calculateMedian(prices) case models.PriceMethodAverage: finalPrice = calculateAverage(prices) default: finalPrice = calculateMedian(prices) } if finalPrice <= 0 { return } // Apply coefficient if comp.PriceCoefficient != 0 { finalPrice = finalPrice * (1 + comp.PriceCoefficient/100) } now := time.Now() // Only update price, preserve all user settings h.db.Model(&models.LotMetadata{}). Where("lot_name = ?", lotName). Updates(map[string]interface{}{ "current_price": finalPrice, "price_updated_at": now, }) } func (h *PricingHandler) RecalculateAll(c *gin.Context) { // Check if we're in offline mode if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Пересчёт цен доступен только в онлайн режиме", "offline": true, }) return } // Set headers for SSE c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") // Get all components with their settings var components []models.LotMetadata h.db.Find(&components) total := int64(len(components)) // Pre-load all lot names for efficient wildcard matching var allLotNames []string h.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames) lotNameSet := make(map[string]bool, len(allLotNames)) for _, ln := range allLotNames { lotNameSet[ln] = true } // Pre-load latest quote dates for all lots (for checking updates) type LotDate struct { Lot string Date time.Time } var latestDates []LotDate h.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates) lotLatestDate := make(map[string]time.Time, len(latestDates)) for _, ld := range latestDates { lotLatestDate[ld.Lot] = ld.Date } // Send initial progress c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"}) c.Writer.Flush() // Process components individually to respect their settings var updated, skipped, manual, unchanged, errors int now := time.Now() progressCounter := 0 for _, comp := range components { progressCounter++ // If manual price is set, skip recalculation if comp.ManualPrice != nil && *comp.ManualPrice > 0 { manual++ goto sendProgress } // Calculate price based on component's individual settings { periodDays := comp.PricePeriodDays method := comp.PriceMethod if method == "" { method = models.PriceMethodMedian } // Determine source lots for price calculation (using cached lot names) var sourceLots []string if comp.MetaPrices != "" { sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames) } else { sourceLots = []string{comp.LotName} } if len(sourceLots) == 0 { skipped++ goto sendProgress } // Check if there are new quotes since last update (using cached dates) if comp.PriceUpdatedAt != nil { hasNewData := false for _, lot := range sourceLots { if latestDate, ok := lotLatestDate[lot]; ok { if latestDate.After(*comp.PriceUpdatedAt) { hasNewData = true break } } } if !hasNewData { unchanged++ goto sendProgress } } // Get prices from source lots var prices []float64 if periodDays > 0 { h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, sourceLots, periodDays).Pluck("price", &prices) } else { h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices) } // If no prices in period, try all time if len(prices) == 0 && periodDays > 0 { h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices) } if len(prices) == 0 { skipped++ goto sendProgress } // Calculate price based on method var basePrice float64 switch method { case models.PriceMethodMedian: basePrice = calculateMedian(prices) case models.PriceMethodAverage: basePrice = calculateAverage(prices) default: basePrice = calculateMedian(prices) } if basePrice <= 0 { skipped++ goto sendProgress } finalPrice := basePrice // Apply coefficient if comp.PriceCoefficient != 0 { finalPrice = finalPrice * (1 + comp.PriceCoefficient/100) } // Update only price fields err := h.db.Model(&models.LotMetadata{}). Where("lot_name = ?", comp.LotName). Updates(map[string]interface{}{ "current_price": finalPrice, "price_updated_at": now, }).Error if err != nil { errors++ } else { updated++ } } sendProgress: // Send progress update every 10 components to reduce overhead if progressCounter%10 == 0 || progressCounter == int(total) { c.SSEvent("progress", gin.H{ "current": updated + skipped + manual + unchanged + errors, "total": total, "updated": updated, "skipped": skipped, "manual": manual, "unchanged": unchanged, "errors": errors, "status": "processing", "lot_name": comp.LotName, }) c.Writer.Flush() } } // Update popularity scores h.statsRepo.UpdatePopularityScores() // Send completion c.SSEvent("progress", gin.H{ "current": updated + skipped + manual + unchanged + errors, "total": total, "updated": updated, "skipped": skipped, "manual": manual, "unchanged": unchanged, "errors": errors, "status": "completed", }) c.Writer.Flush() } func (h *PricingHandler) ListAlerts(c *gin.Context) { // Check if we're in offline mode if h.db == nil { c.JSON(http.StatusOK, gin.H{ "alerts": []interface{}{}, "total": 0, "page": 1, "per_page": 20, "offline": true, }) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) filter := repository.AlertFilter{ Status: models.AlertStatus(c.Query("status")), Severity: models.AlertSeverity(c.Query("severity")), Type: models.AlertType(c.Query("type")), LotName: c.Query("lot_name"), } alertsList, total, err := h.alertService.List(filter, page, perPage) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "alerts": alertsList, "total": total, "page": page, "per_page": perPage, }) } func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) { // Check if we're in offline mode if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Управление алертами доступно только в онлайн режиме", "offline": true, }) return } id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) return } if err := h.alertService.Acknowledge(uint(id)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "acknowledged"}) } func (h *PricingHandler) ResolveAlert(c *gin.Context) { // Check if we're in offline mode if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Управление алертами доступно только в онлайн режиме", "offline": true, }) return } id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) return } if err := h.alertService.Resolve(uint(id)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "resolved"}) } func (h *PricingHandler) IgnoreAlert(c *gin.Context) { // Check if we're in offline mode if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Управление алертами доступно только в онлайн режиме", "offline": true, }) return } id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) return } if err := h.alertService.Ignore(uint(id)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "ignored"}) } type PreviewPriceRequest struct { LotName string `json:"lot_name" binding:"required"` Method string `json:"method"` PeriodDays int `json:"period_days"` Coefficient float64 `json:"coefficient"` MetaEnabled bool `json:"meta_enabled"` MetaPrices string `json:"meta_prices"` } func (h *PricingHandler) PreviewPrice(c *gin.Context) { // Check if we're in offline mode if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Предпросмотр цены доступен только в онлайн режиме", "offline": true, }) return } var req PreviewPriceRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Get component var comp models.LotMetadata if err := h.db.Where("lot_name = ?", req.LotName).First(&comp).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "component not found"}) return } // Determine which lot names to use for price calculation lotNames := []string{req.LotName} if req.MetaEnabled && req.MetaPrices != "" { lotNames = h.expandMetaPrices(req.MetaPrices, req.LotName) } // Get all prices for calculations (from all relevant lots) var allPrices []float64 for _, lotName := range lotNames { var lotPrices []float64 if strings.HasSuffix(lotName, "*") { // Wildcard pattern pattern := strings.TrimSuffix(lotName, "*") + "%" h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices) } else { h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &lotPrices) } allPrices = append(allPrices, lotPrices...) } // Calculate median for all time var medianAllTime *float64 if len(allPrices) > 0 { sortFloat64s(allPrices) median := calculateMedian(allPrices) medianAllTime = &median } // Get quote count (from all relevant lots) - total count var quoteCountTotal int64 for _, lotName := range lotNames { var count int64 if strings.HasSuffix(lotName, "*") { pattern := strings.TrimSuffix(lotName, "*") + "%" h.db.Model(&models.LotLog{}).Where("lot LIKE ?", pattern).Count(&count) } else { h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count) } quoteCountTotal += count } // Get quote count for specified period (if period is > 0) var quoteCountPeriod int64 if req.PeriodDays > 0 { for _, lotName := range lotNames { var count int64 if strings.HasSuffix(lotName, "*") { pattern := strings.TrimSuffix(lotName, "*") + "%" h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, pattern, req.PeriodDays).Scan(&count) } else { h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, lotName, req.PeriodDays).Scan(&count) } quoteCountPeriod += count } } else { // If no period specified, period count equals total count quoteCountPeriod = quoteCountTotal } // Get last received price (from the main lot only) var lastPrice struct { Price *float64 Date *time.Time } h.db.Raw(`SELECT price, date FROM lot_log WHERE lot = ? ORDER BY date DESC, lot_log_id DESC LIMIT 1`, req.LotName).Scan(&lastPrice) // Calculate new price based on parameters (method, period, coefficient) method := req.Method if method == "" { method = "median" } var prices []float64 if req.PeriodDays > 0 { for _, lotName := range lotNames { var lotPrices []float64 if strings.HasSuffix(lotName, "*") { pattern := strings.TrimSuffix(lotName, "*") + "%" h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, pattern, req.PeriodDays).Pluck("price", &lotPrices) } else { h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, lotName, req.PeriodDays).Pluck("price", &lotPrices) } prices = append(prices, lotPrices...) } // Fall back to all time if no prices in period if len(prices) == 0 { prices = allPrices } } else { prices = allPrices } var newPrice *float64 if len(prices) > 0 { sortFloat64s(prices) var basePrice float64 if method == "average" { basePrice = calculateAverage(prices) } else { basePrice = calculateMedian(prices) } if req.Coefficient != 0 { basePrice = basePrice * (1 + req.Coefficient/100) } newPrice = &basePrice } c.JSON(http.StatusOK, gin.H{ "lot_name": req.LotName, "current_price": comp.CurrentPrice, "median_all_time": medianAllTime, "new_price": newPrice, "quote_count_total": quoteCountTotal, "quote_count_period": quoteCountPeriod, "manual_price": comp.ManualPrice, "last_price": lastPrice.Price, "last_price_date": lastPrice.Date, }) } // sortFloat64s sorts a slice of float64 in ascending order func sortFloat64s(data []float64) { sort.Float64s(data) } // expandMetaPricesWithCache expands meta_prices using pre-loaded lot names (no DB queries) func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string { sources := strings.Split(metaPrices, ",") var result []string seen := make(map[string]bool) for _, source := range sources { source = strings.TrimSpace(source) if source == "" || source == excludeLot { continue } if strings.HasSuffix(source, "*") { // Wildcard pattern - find matching lots from cache prefix := strings.TrimSuffix(source, "*") for _, lot := range allLotNames { if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] { result = append(result, lot) seen[lot] = true } } } else if !seen[source] { result = append(result, source) seen[source] = true } } return result }