Files
QuoteForge/internal/handlers/pricelist.go

628 lines
17 KiB
Go

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",
})
}