628 lines
17 KiB
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",
|
|
})
|
|
}
|