package handlers import ( "errors" "fmt" "io" "net/http" "sort" "strconv" "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/services/pricelist" "github.com/gin-gonic/gin" ) type PricelistHandler struct { service *pricelist.Service localDB *localdb.LocalDB } func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler { return &PricelistHandler{service: service, localDB: localDB} } // refreshLocalPricelistCacheFromServer rehydrates local metadata + items for one server pricelist. func (h *PricelistHandler) refreshLocalPricelistCacheFromServer(serverID uint, onProgress func(synced, total int, message string)) error { if h.localDB == nil { return nil } report := func(synced, total int, message string) { if onProgress != nil { onProgress(synced, total, message) } } report(0, 0, "Подготовка локального кэша") pl, err := h.service.GetByID(serverID) if err != nil { return err } if existing, err := h.localDB.GetLocalPricelistByServerID(serverID); err == nil { if err := h.localDB.DeleteLocalPricelist(existing.ID); err != nil { return err } } localPL := &localdb.LocalPricelist{ ServerID: pl.ID, Source: pl.Source, Version: pl.Version, Name: pl.Notification, CreatedAt: pl.CreatedAt, SyncedAt: time.Now(), IsUsed: false, } if err := h.localDB.SaveLocalPricelist(localPL); err != nil { return err } report(0, 0, "Локальный кэш обновлён") // Ensure we use persisted local row id (upsert path may not populate struct ID reliably). persistedLocalPL, err := h.localDB.GetLocalPricelistByServerID(serverID) if err != nil { return err } if persistedLocalPL.ID == 0 { return fmt.Errorf("local pricelist id is zero after save (server_id=%d)", serverID) } const perPage = 2000 synced := 0 totalItems := 0 gotTotal := false for page := 1; ; page++ { items, total, err := h.service.GetItems(serverID, page, perPage, "") if err != nil { return err } if !gotTotal { totalItems = int(total) gotTotal = true } if len(items) == 0 { break } localItems := make([]localdb.LocalPricelistItem, 0, len(items)) for _, item := range items { partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers)) partnumbers = append(partnumbers, item.Partnumbers...) localItems = append(localItems, localdb.LocalPricelistItem{ PricelistID: persistedLocalPL.ID, LotName: item.LotName, Price: item.Price, AvailableQty: item.AvailableQty, Partnumbers: partnumbers, }) } if err := h.localDB.SaveLocalPricelistItems(localItems); err != nil { return err } synced += len(localItems) report(synced, totalItems, "Синхронизация позиций в локальный кэш") if int64(page*perPage) >= total { break } } report(synced, totalItems, "Локальный кэш синхронизирован") return nil } // List returns all pricelists with pagination func (h *PricelistHandler) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } source := c.Query("source") activeOnly := c.DefaultQuery("active_only", "false") == "true" localPLs, err := h.localDB.GetLocalPricelists() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if source != "" { filtered := localPLs[:0] for _, lpl := range localPLs { if strings.EqualFold(lpl.Source, source) { filtered = append(filtered, lpl) } } localPLs = filtered } if activeOnly { // Local cache stores only active snapshots for normal operations. } sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) }) total := len(localPLs) start := (page - 1) * perPage if start > total { start = total } end := start + perPage if end > total { end = total } pageSlice := localPLs[start:end] summaries := make([]map[string]interface{}, 0, len(pageSlice)) for _, lpl := range pageSlice { itemCount := h.localDB.CountLocalPricelistItems(lpl.ID) usageCount := 0 if lpl.IsUsed { usageCount = 1 } summaries = append(summaries, map[string]interface{}{ "id": lpl.ServerID, "source": lpl.Source, "version": lpl.Version, "created_by": "sync", "item_count": itemCount, "usage_count": usageCount, "is_active": true, "created_at": lpl.CreatedAt, "synced_from": "local", }) } c.JSON(http.StatusOK, gin.H{ "pricelists": summaries, "total": total, "page": page, "per_page": perPage, }) } // Get returns a single pricelist by ID func (h *PricelistHandler) Get(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) return } localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"}) return } c.JSON(http.StatusOK, gin.H{ "id": localPL.ServerID, "source": localPL.Source, "version": localPL.Version, "created_by": "sync", "item_count": h.localDB.CountLocalPricelistItems(localPL.ID), "is_active": true, "created_at": localPL.CreatedAt, "synced_from": "local", }) } // Create creates a new pricelist from current prices func (h *PricelistHandler) Create(c *gin.Context) { canWrite, debugInfo := h.service.CanWriteDebug() if !canWrite { c.JSON(http.StatusForbidden, gin.H{ "error": "pricelist write is not allowed", "debug": debugInfo, }) return } var req struct { Source string `json:"source"` Items []struct { LotName string `json:"lot_name"` Price float64 `json:"price"` } `json:"items"` } if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } source := string(models.NormalizePricelistSource(req.Source)) // Get the database username as the creator createdBy := h.localDB.GetDBUser() if createdBy == "" { createdBy = "unknown" } sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items)) for _, item := range req.Items { sourceItems = append(sourceItems, pricelist.CreateItemInput{ LotName: item.LotName, Price: item.Price, }) } pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Keep local cache consistent for local-first reads (metadata + items). if err := h.refreshLocalPricelistCacheFromServer(pl.ID, nil); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "pricelist created on server but failed to refresh local cache: " + err.Error(), }) return } c.JSON(http.StatusCreated, pl) } // CreateWithProgress creates a pricelist and streams progress updates over SSE. func (h *PricelistHandler) CreateWithProgress(c *gin.Context) { canWrite, debugInfo := h.service.CanWriteDebug() if !canWrite { c.JSON(http.StatusForbidden, gin.H{ "error": "pricelist write is not allowed", "debug": debugInfo, }) return } var req struct { Source string `json:"source"` Items []struct { LotName string `json:"lot_name"` Price float64 `json:"price"` } `json:"items"` } if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } source := string(models.NormalizePricelistSource(req.Source)) createdBy := h.localDB.GetDBUser() if createdBy == "" { createdBy = "unknown" } sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items)) for _, item := range req.Items { sourceItems = append(sourceItems, pricelist.CreateItemInput{ LotName: item.LotName, Price: item.Price, }) } c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") flusher, ok := c.Writer.(http.Flusher) if !ok { pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, pl) return } sendProgress := func(payload gin.H) { c.SSEvent("progress", payload) flusher.Flush() } sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."}) pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, func(p pricelist.CreateProgress) { // Composite progress: 0-85% server creation, 86-99% local cache sync. current := int(float64(p.Current) * 0.85) if p.Status == "completed" { current = 85 } status := p.Status if status == "completed" { status = "server_completed" } sendProgress(gin.H{ "current": current, "total": p.Total, "status": status, "message": p.Message, "updated": p.Updated, "errors": p.Errors, "lot_name": p.LotName, }) }) if err != nil { sendProgress(gin.H{ "current": 0, "total": 4, "status": "error", "message": fmt.Sprintf("Ошибка: %v", err), }) return } if err := h.refreshLocalPricelistCacheFromServer(pl.ID, func(synced, total int, message string) { current := 86 if total > 0 { progressPart := int(float64(synced) / float64(total) * 13.0) // 86..99 if progressPart > 13 { progressPart = 13 } current = 86 + progressPart } if current > 99 { current = 99 } sendProgress(gin.H{ "current": current, "total": 100, "status": "sync_local_cache", "message": message, }) }); err != nil { sendProgress(gin.H{ "current": 4, "total": 4, "status": "error", "message": fmt.Sprintf("Прайслист создан, но локальный кэш не обновлён: %v", err), }) return } sendProgress(gin.H{ "current": 4, "total": 4, "status": "completed", "message": "Готово", "pricelist": pl, }) } // Delete deletes a pricelist by ID func (h *PricelistHandler) Delete(c *gin.Context) { canWrite, debugInfo := h.service.CanWriteDebug() if !canWrite { c.JSON(http.StatusForbidden, gin.H{ "error": "pricelist write is not allowed", "debug": debugInfo, }) return } idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) return } if err := h.service.Delete(uint(id)); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Local-first UI reads pricelists from SQLite cache. Keep cache in sync right away. if h.localDB != nil { if localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id)); err == nil { if err := h.localDB.DeleteLocalPricelist(localPL.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "pricelist deleted on server but failed to update local cache: " + err.Error(), }) return } } } c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"}) } // SetActive toggles active flag on a pricelist. func (h *PricelistHandler) SetActive(c *gin.Context) { canWrite, debugInfo := h.service.CanWriteDebug() if !canWrite { c.JSON(http.StatusForbidden, gin.H{ "error": "pricelist write is not allowed", "debug": debugInfo, }) return } idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) return } var req struct { IsActive bool `json:"is_active"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.service.SetActive(uint(id), req.IsActive); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Local-first table stores only active snapshots. Reflect toggles immediately. if h.localDB != nil { localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id)) if err == nil { if req.IsActive { // Ensure local active row has complete cache (metadata + items). if h.localDB.CountLocalPricelistItems(localPL.ID) == 0 { if err := h.refreshLocalPricelistCacheFromServer(uint(id), nil); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "updated on server but failed to refresh local cache: " + err.Error(), }) return } } else { localPL.SyncedAt = time.Now() if saveErr := h.localDB.SaveLocalPricelist(localPL); saveErr != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "updated on server but failed to update local cache: " + saveErr.Error(), }) return } } } else { // Inactive entries should disappear from local active cache list. if delErr := h.localDB.DeleteLocalPricelist(localPL.ID); delErr != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "updated on server but failed to update local cache: " + delErr.Error(), }) return } } } else if req.IsActive { if err := h.refreshLocalPricelistCacheFromServer(uint(id), nil); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "updated on server but failed to seed local cache: " + err.Error(), }) return } } } c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive}) } // GetItems returns items for a pricelist with pagination func (h *PricelistHandler) GetItems(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) search := c.Query("search") localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"}) return } if page < 1 { page = 1 } if perPage < 1 { perPage = 50 } var items []localdb.LocalPricelistItem dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID) if strings.TrimSpace(search) != "" { dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%") } var total int64 if err := dbq.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } offset := (page - 1) * perPage if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } resultItems := make([]gin.H, 0, len(items)) for _, item := range items { category := "" if parts := strings.SplitN(item.LotName, "_", 2); len(parts) > 0 { category = parts[0] } resultItems = append(resultItems, gin.H{ "id": item.ID, "lot_name": item.LotName, "price": item.Price, "category": category, "available_qty": item.AvailableQty, "partnumbers": []string(item.Partnumbers), }) } c.JSON(http.StatusOK, gin.H{ "source": localPL.Source, "items": resultItems, "total": total, "page": page, "per_page": perPage, }) } func (h *PricelistHandler) GetLotNames(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) return } localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"}) return } items, err := h.localDB.GetLocalPricelistItems(localPL.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } lotNames := make([]string, 0, len(items)) for _, item := range items { lotNames = append(lotNames, item.LotName) } sort.Strings(lotNames) c.JSON(http.StatusOK, gin.H{ "lot_names": lotNames, "total": len(lotNames), }) } // CanWrite returns whether the current user can create pricelists func (h *PricelistHandler) CanWrite(c *gin.Context) { canWrite, debugInfo := h.service.CanWriteDebug() c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo}) } // GetLatest returns the most recent active pricelist func (h *PricelistHandler) GetLatest(c *gin.Context) { source := c.DefaultQuery("source", string(models.PricelistSourceEstimate)) source = string(models.NormalizePricelistSource(source)) localPL, err := h.localDB.GetLatestLocalPricelistBySource(source) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "no pricelists available"}) return } c.JSON(http.StatusOK, gin.H{ "id": localPL.ServerID, "source": localPL.Source, "version": localPL.Version, "created_by": "sync", "item_count": h.localDB.CountLocalPricelistItems(localPL.ID), "is_active": true, "created_at": localPL.CreatedAt, "synced_from": "local", }) }