package handlers import ( "io" "net/http" "os" "sort" "strconv" "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services/alerts" "git.mchus.pro/mchus/quoteforge/internal/services/pricing" "git.mchus.pro/mchus/quoteforge/internal/warehouse" "github.com/gin-gonic/gin" "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 stockImportService *services.StockImportService dbUsername string } func NewPricingHandler( db *gorm.DB, pricingService *pricing.Service, alertService *alerts.Service, componentRepo *repository.ComponentRepository, priceRepo *repository.PriceRepository, statsRepo *repository.StatsRepository, stockImportService *services.StockImportService, dbUsername string, ) *PricingHandler { return &PricingHandler{ db: db, pricingService: pricingService, alertService: alertService, componentRepo: componentRepo, priceRepo: priceRepo, statsRepo: statsRepo, stockImportService: stockImportService, dbUsername: dbUsername, } } 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 } func (h *PricingHandler) ImportStockLog(c *gin.Context) { if h.stockImportService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Импорт склада доступен только в онлайн режиме", "offline": true, }) return } fileHeader, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) return } file, err := fileHeader.Open() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"}) return } defer file.Close() content, err := io.ReadAll(file) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read uploaded file"}) return } modTime := time.Now() if statter, ok := file.(interface{ Stat() (os.FileInfo, error) }); ok { if st, statErr := statter.Stat(); statErr == nil { modTime = st.ModTime() } } flusher, ok := c.Writer.(http.Flusher) if !ok { result, impErr := h.stockImportService.Import(fileHeader.Filename, content, modTime, h.dbUsername, nil) if impErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": impErr.Error()}) return } c.JSON(http.StatusOK, gin.H{ "status": "completed", "rows_total": result.RowsTotal, "valid_rows": result.ValidRows, "inserted": result.Inserted, "deleted": result.Deleted, "unmapped": result.Unmapped, "conflicts": result.Conflicts, "fallback_matches": result.FallbackMatches, "parse_errors": result.ParseErrors, "qty_parse_errors": result.QtyParseErrors, "ignored": result.Ignored, "mapping_suggestions": result.MappingSuggestions, "import_date": result.ImportDate.Format("2006-01-02"), "warehouse_pricelist_id": result.WarehousePLID, "warehouse_pricelist_version": result.WarehousePLVer, }) return } c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") send := func(p gin.H) { c.SSEvent("progress", p) flusher.Flush() } send(gin.H{"status": "starting", "message": "Запуск импорта"}) _, impErr := h.stockImportService.Import(fileHeader.Filename, content, modTime, h.dbUsername, func(p services.StockImportProgress) { send(gin.H{ "status": p.Status, "message": p.Message, "current": p.Current, "total": p.Total, "rows_total": p.RowsTotal, "valid_rows": p.ValidRows, "inserted": p.Inserted, "deleted": p.Deleted, "unmapped": p.Unmapped, "conflicts": p.Conflicts, "fallback_matches": p.FallbackMatches, "parse_errors": p.ParseErrors, "qty_parse_errors": p.QtyParseErrors, "ignored": p.Ignored, "mapping_suggestions": p.MappingSuggestions, "import_date": p.ImportDate, "warehouse_pricelist_id": p.PricelistID, "warehouse_pricelist_version": p.PricelistVer, }) }) if impErr != nil { send(gin.H{"status": "error", "message": impErr.Error()}) return } } func (h *PricingHandler) ListStockMappings(c *gin.Context) { if h.stockImportService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Сопоставления доступны только в онлайн режиме", "offline": true, }) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) search := c.Query("search") rows, total, err := h.stockImportService.ListMappings(page, perPage, search) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "items": rows, "total": total, "page": page, "per_page": perPage, }) } func (h *PricingHandler) UpsertStockMapping(c *gin.Context) { if h.stockImportService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Сопоставления доступны только в онлайн режиме", "offline": true, }) return } var req struct { Partnumber string `json:"partnumber" binding:"required"` LotName string `json:"lot_name"` Description string `json:"description"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stockImportService.UpsertMapping(req.Partnumber, req.LotName, req.Description); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "mapping saved"}) } func (h *PricingHandler) DeleteStockMapping(c *gin.Context) { if h.stockImportService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Сопоставления доступны только в онлайн режиме", "offline": true, }) return } partnumber := c.Param("partnumber") deleted, err := h.stockImportService.DeleteMapping(partnumber) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"deleted": deleted}) } func (h *PricingHandler) ListStockIgnoreRules(c *gin.Context) { if h.stockImportService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Правила игнорирования доступны только в онлайн режиме", "offline": true, }) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) rows, total, err := h.stockImportService.ListIgnoreRules(page, perPage) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "items": rows, "total": total, "page": page, "per_page": perPage, }) } func (h *PricingHandler) UpsertStockIgnoreRule(c *gin.Context) { if h.stockImportService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Правила игнорирования доступны только в онлайн режиме", "offline": true, }) return } var req struct { Target string `json:"target" binding:"required"` MatchType string `json:"match_type" binding:"required"` Pattern string `json:"pattern" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stockImportService.UpsertIgnoreRule(req.Target, req.MatchType, req.Pattern); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "ignore rule saved"}) } func (h *PricingHandler) DeleteStockIgnoreRule(c *gin.Context) { if h.stockImportService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Правила игнорирования доступны только в онлайн режиме", "offline": true, }) return } id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } deleted, err := h.stockImportService.DeleteIgnoreRule(uint(id)) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"deleted": deleted}) } type LotTableRow struct { LotName string `json:"lot_name"` LotDescription string `json:"lot_description"` Category string `json:"category"` Partnumbers []string `json:"partnumbers"` Popularity float64 `json:"popularity"` EstimateCount int64 `json:"estimate_count"` StockQty *float64 `json:"stock_qty"` } func (h *PricingHandler) ListLotsTable(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Список LOT доступен только в онлайн режиме", "offline": true, }) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) search := strings.TrimSpace(c.Query("search")) sortFieldParam := c.DefaultQuery("sort", "lot_name") sortDirParam := strings.ToUpper(c.DefaultQuery("dir", "asc")) if page < 1 { page = 1 } if perPage < 1 || perPage > 200 { perPage = 50 } if sortDirParam != "ASC" && sortDirParam != "DESC" { sortDirParam = "ASC" } type lotRow struct { LotName string `gorm:"column:lot_name"` LotDescription string `gorm:"column:lot_description"` CategoryCode *string `gorm:"column:category_code"` Popularity *float64 `gorm:"column:popularity_score"` } baseQuery := h.db.Table("lot"). Select("lot.lot_name, lot.lot_description, qt_categories.code as category_code, qt_lot_metadata.popularity_score"). Joins("LEFT JOIN qt_lot_metadata ON qt_lot_metadata.lot_name = lot.lot_name"). Joins("LEFT JOIN qt_categories ON qt_categories.id = qt_lot_metadata.category_id") if search != "" { baseQuery = baseQuery.Where("lot.lot_name LIKE ? OR lot.lot_description LIKE ?", "%"+search+"%", "%"+search+"%") } var total int64 if err := baseQuery.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } allowedDBSorts := map[string]string{ "lot_name": "lot.lot_name", "category": "qt_categories.code", "popularity_score": "qt_lot_metadata.popularity_score", } needsComputedSort := sortFieldParam == "estimate_count" || sortFieldParam == "stock_qty" var rows []lotRow rowsQuery := baseQuery.Session(&gorm.Session{}) if needsComputedSort { rowsQuery = rowsQuery.Order("lot.lot_name ASC") } else { orderCol, ok := allowedDBSorts[sortFieldParam] if !ok { orderCol = "lot.lot_name" } rowsQuery = rowsQuery.Order(orderCol + " " + sortDirParam).Order("lot.lot_name ASC") rowsQuery = rowsQuery.Offset((page - 1) * perPage).Limit(perPage) } if err := rowsQuery.Find(&rows).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if len(rows) == 0 { c.JSON(http.StatusOK, gin.H{ "lots": []LotTableRow{}, "total": total, "page": page, "per_page": perPage, }) return } // Collect lot names for batch subqueries lotNames := make([]string, len(rows)) for i, r := range rows { lotNames[i] = r.LotName } type countRow struct { Lot string `gorm:"column:lot"` Count int64 `gorm:"column:cnt"` } var estimateCounts []countRow if err := h.db.Raw("SELECT lot, COUNT(*) as cnt FROM lot_log WHERE lot IN ? GROUP BY lot", lotNames).Scan(&estimateCounts).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } estimateMap := make(map[string]int64, len(estimateCounts)) for _, ec := range estimateCounts { estimateMap[ec.Lot] = ec.Count } stockQtyByLot, pnMap, err := warehouse.LoadLotMetrics(h.db, lotNames, true) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } result := make([]LotTableRow, len(rows)) for i, r := range rows { cat := "" if r.CategoryCode != nil { cat = *r.CategoryCode } pop := 0.0 if r.Popularity != nil { pop = *r.Popularity } result[i] = LotTableRow{ LotName: r.LotName, LotDescription: r.LotDescription, Category: cat, Partnumbers: pnMap[r.LotName], Popularity: pop, EstimateCount: estimateMap[r.LotName], } if qty, ok := stockQtyByLot[r.LotName]; ok { q := qty result[i].StockQty = &q } if result[i].Partnumbers == nil { result[i].Partnumbers = []string{} } } if needsComputedSort { sort.SliceStable(result, func(i, j int) bool { if sortFieldParam == "estimate_count" { if result[i].EstimateCount == result[j].EstimateCount { if sortDirParam == "DESC" { return result[i].LotName > result[j].LotName } return result[i].LotName < result[j].LotName } if sortDirParam == "DESC" { return result[i].EstimateCount > result[j].EstimateCount } return result[i].EstimateCount < result[j].EstimateCount } qi := 0.0 if result[i].StockQty != nil { qi = *result[i].StockQty } qj := 0.0 if result[j].StockQty != nil { qj = *result[j].StockQty } if qi == qj { if sortDirParam == "DESC" { return result[i].LotName > result[j].LotName } return result[i].LotName < result[j].LotName } if sortDirParam == "DESC" { return qi > qj } return qi < qj }) start := (page - 1) * perPage if start >= len(result) { result = []LotTableRow{} } else { end := start + perPage if end > len(result) { end = len(result) } result = result[start:end] } } c.JSON(http.StatusOK, gin.H{ "lots": result, "total": total, "page": page, "per_page": perPage, }) } func (h *PricingHandler) ListLots(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Список LOT доступен только в онлайн режиме", "offline": true, }) return } perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "500")) if perPage < 1 { perPage = 500 } if perPage > 5000 { perPage = 5000 } search := strings.TrimSpace(c.Query("search")) query := h.db.Model(&models.Lot{}).Select("lot_name") if search != "" { query = query.Where("lot_name LIKE ?", "%"+search+"%") } var lots []models.Lot if err := query.Order("lot_name ASC").Limit(perPage).Find(&lots).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } items := make([]string, 0, len(lots)) for _, lot := range lots { if strings.TrimSpace(lot.LotName) == "" { continue } items = append(items, lot.LotName) } c.JSON(http.StatusOK, gin.H{"items": items}) }