Remove admin pricing stack and prepare v1.0.4 release
This commit is contained in:
@@ -1,121 +1,25 @@
|
||||
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}
|
||||
func NewPricelistHandler(localDB *localdb.LocalDB) *PricelistHandler {
|
||||
return &PricelistHandler{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
|
||||
// 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"))
|
||||
@@ -184,7 +88,7 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Get returns a single pricelist by ID
|
||||
// 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)
|
||||
@@ -211,299 +115,7 @@ func (h *PricelistHandler) Get(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
@@ -598,13 +210,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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))
|
||||
|
||||
Reference in New Issue
Block a user