Remove admin pricing stack and prepare v1.0.4 release
This commit is contained in:
@@ -3,8 +3,10 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -25,6 +27,12 @@ func NewComponentHandler(componentService *services.ComponentService, localDB *l
|
||||
func (h *ComponentHandler) 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
|
||||
}
|
||||
|
||||
filter := repository.ComponentFilter{
|
||||
Category: c.Query("category"),
|
||||
@@ -33,73 +41,70 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
||||
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
|
||||
}
|
||||
|
||||
result, err := h.componentService.List(filter, page, perPage)
|
||||
localFilter := localdb.ComponentFilter{
|
||||
Category: filter.Category,
|
||||
Search: filter.Search,
|
||||
HasPrice: filter.HasPrice,
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// If offline mode (empty result), fallback to local components
|
||||
isOffline := false
|
||||
if v, ok := c.Get("is_offline"); ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
isOffline = b
|
||||
}
|
||||
}
|
||||
if isOffline && result.Total == 0 && h.localDB != nil {
|
||||
localFilter := localdb.ComponentFilter{
|
||||
Category: filter.Category,
|
||||
Search: filter.Search,
|
||||
HasPrice: filter.HasPrice,
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||
if err == nil && len(localComps) > 0 {
|
||||
// Convert local components to ComponentView format
|
||||
components := make([]services.ComponentView, len(localComps))
|
||||
for i, lc := range localComps {
|
||||
components[i] = services.ComponentView{
|
||||
LotName: lc.LotName,
|
||||
Description: lc.LotDescription,
|
||||
Category: lc.Category,
|
||||
CategoryName: lc.Category, // No translation in local mode
|
||||
Model: lc.Model,
|
||||
CurrentPrice: lc.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &services.ComponentListResult{
|
||||
Components: components,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
return
|
||||
components := make([]services.ComponentView, len(localComps))
|
||||
for i, lc := range localComps {
|
||||
components[i] = services.ComponentView{
|
||||
LotName: lc.LotName,
|
||||
Description: lc.LotDescription,
|
||||
Category: lc.Category,
|
||||
CategoryName: lc.Category,
|
||||
Model: lc.Model,
|
||||
CurrentPrice: lc.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
c.JSON(http.StatusOK, &services.ComponentListResult{
|
||||
Components: components,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) Get(c *gin.Context) {
|
||||
lotName := c.Param("lot_name")
|
||||
|
||||
component, err := h.componentService.GetByLotName(lotName)
|
||||
component, err := h.localDB.GetLocalComponent(lotName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, component)
|
||||
c.JSON(http.StatusOK, services.ComponentView{
|
||||
LotName: component.LotName,
|
||||
Description: component.LotDescription,
|
||||
Category: component.Category,
|
||||
CategoryName: component.Category,
|
||||
Model: component.Model,
|
||||
CurrentPrice: component.CurrentPrice,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
||||
categories, err := h.componentService.GetCategories()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
codes, err := h.localDB.GetLocalComponentCategories()
|
||||
if err == nil && len(codes) > 0 {
|
||||
categories := make([]models.Category, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
categories = append(categories, models.Category{Code: trimmed, Name: trimmed})
|
||||
}
|
||||
c.JSON(http.StatusOK, categories)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, categories)
|
||||
c.JSON(http.StatusOK, models.DefaultCategories)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
stdsync "sync"
|
||||
"time"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
@@ -24,6 +26,9 @@ type SyncHandler struct {
|
||||
autoSyncInterval time.Duration
|
||||
onlineGraceFactor float64
|
||||
tmpl *template.Template
|
||||
readinessMu stdsync.Mutex
|
||||
readinessCached *sync.SyncReadiness
|
||||
readinessCachedAt time.Time
|
||||
}
|
||||
|
||||
// NewSyncHandler creates a new sync handler
|
||||
@@ -53,14 +58,24 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
||||
|
||||
// SyncStatusResponse represents the sync status
|
||||
type SyncStatusResponse struct {
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||
}
|
||||
|
||||
type SyncReadinessResponse struct {
|
||||
Status string `json:"status"`
|
||||
Blocked bool `json:"blocked"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
ReasonText string `json:"reason_text,omitempty"`
|
||||
RequiredMinAppVersion *string `json:"required_min_app_version,omitempty"`
|
||||
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||
}
|
||||
|
||||
// GetStatus returns current sync status
|
||||
@@ -90,6 +105,7 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
|
||||
// Check if component sync is needed (older than 24 hours)
|
||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
|
||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||
LastComponentSync: lastComponentSync,
|
||||
@@ -100,9 +116,63 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
ServerPricelists: serverPricelists,
|
||||
NeedComponentSync: needComponentSync,
|
||||
NeedPricelistSync: needPricelistSync,
|
||||
Readiness: readiness,
|
||||
})
|
||||
}
|
||||
|
||||
// GetReadiness returns sync readiness guard status.
|
||||
// GET /api/sync/readiness
|
||||
func (h *SyncHandler) GetReadiness(c *gin.Context) {
|
||||
readiness, err := h.syncService.GetReadiness()
|
||||
if err != nil && readiness == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if readiness == nil {
|
||||
c.JSON(http.StatusOK, SyncReadinessResponse{Status: sync.ReadinessUnknown, Blocked: false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, SyncReadinessResponse{
|
||||
Status: readiness.Status,
|
||||
Blocked: readiness.Blocked,
|
||||
ReasonCode: readiness.ReasonCode,
|
||||
ReasonText: readiness.ReasonText,
|
||||
RequiredMinAppVersion: readiness.RequiredMinAppVersion,
|
||||
LastCheckedAt: readiness.LastCheckedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
|
||||
readiness, err := h.syncService.EnsureReadinessForSync()
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
blocked := &sync.SyncBlockedError{}
|
||||
if errors.As(err, &blocked) {
|
||||
c.JSON(http.StatusLocked, gin.H{
|
||||
"success": false,
|
||||
"error": blocked.Error(),
|
||||
"reason_code": blocked.Readiness.ReasonCode,
|
||||
"reason_text": blocked.Readiness.ReasonText,
|
||||
"required_min_app_version": blocked.Readiness.RequiredMinAppVersion,
|
||||
"status": blocked.Readiness.Status,
|
||||
"blocked": true,
|
||||
"last_checked_at": blocked.Readiness.LastCheckedAt,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
_ = readiness
|
||||
return false
|
||||
}
|
||||
|
||||
// SyncResultResponse represents sync operation result
|
||||
type SyncResultResponse struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -114,11 +184,7 @@ type SyncResultResponse struct {
|
||||
// SyncComponents syncs components from MariaDB to local SQLite
|
||||
// POST /api/sync/components
|
||||
func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||
if !h.checkOnline() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database is offline",
|
||||
})
|
||||
if !h.ensureSyncReadiness(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -153,11 +219,7 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||
// SyncPricelists syncs pricelists from MariaDB to local SQLite
|
||||
// POST /api/sync/pricelists
|
||||
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
if !h.checkOnline() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database is offline",
|
||||
})
|
||||
if !h.ensureSyncReadiness(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -202,11 +264,7 @@ type SyncAllResponse struct {
|
||||
// - pull components, pricelists, projects, and configurations from server
|
||||
// POST /api/sync/all
|
||||
func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
if !h.checkOnline() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database is offline",
|
||||
})
|
||||
if !h.ensureSyncReadiness(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -312,11 +370,7 @@ func (h *SyncHandler) checkOnline() bool {
|
||||
// PushPendingChanges pushes all pending changes to the server
|
||||
// POST /api/sync/push
|
||||
func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
|
||||
if !h.checkOnline() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database is offline",
|
||||
})
|
||||
if !h.ensureSyncReadiness(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -377,9 +431,9 @@ type SyncInfoResponse struct {
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
|
||||
// Statistics
|
||||
LotCount int64 `json:"lot_count"`
|
||||
LotLogCount int64 `json:"lot_log_count"`
|
||||
ConfigCount int64 `json:"config_count"`
|
||||
LotCount int64 `json:"lot_count"`
|
||||
LotLogCount int64 `json:"lot_log_count"`
|
||||
ConfigCount int64 `json:"config_count"`
|
||||
ProjectCount int64 `json:"project_count"`
|
||||
|
||||
// Pending changes
|
||||
@@ -388,6 +442,9 @@ type SyncInfoResponse struct {
|
||||
// Errors
|
||||
ErrorCount int `json:"error_count"`
|
||||
Errors []SyncError `json:"errors,omitempty"`
|
||||
|
||||
// Readiness guard
|
||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||
}
|
||||
|
||||
type SyncUsersStatusResponse struct {
|
||||
@@ -459,6 +516,8 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
syncErrors = syncErrors[:10]
|
||||
}
|
||||
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
|
||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||
DBHost: dbHost,
|
||||
DBUser: dbUser,
|
||||
@@ -472,6 +531,7 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
PendingChanges: changes,
|
||||
ErrorCount: errorCount,
|
||||
Errors: syncErrors,
|
||||
Readiness: readiness,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -528,12 +588,21 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||
|
||||
// Get pending count
|
||||
pendingCount := h.localDB.GetPendingCount()
|
||||
readiness := h.getReadinessCached(10 * time.Second)
|
||||
isBlocked := readiness != nil && readiness.Blocked
|
||||
|
||||
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount)
|
||||
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
|
||||
|
||||
data := gin.H{
|
||||
"IsOffline": isOffline,
|
||||
"PendingCount": pendingCount,
|
||||
"IsBlocked": isBlocked,
|
||||
"BlockedReason": func() string {
|
||||
if readiness == nil {
|
||||
return ""
|
||||
}
|
||||
return readiness.ReasonText
|
||||
}(),
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -542,3 +611,24 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||
c.String(http.StatusInternalServerError, "Template error: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
|
||||
h.readinessMu.Lock()
|
||||
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
|
||||
cached := *h.readinessCached
|
||||
h.readinessMu.Unlock()
|
||||
return &cached
|
||||
}
|
||||
h.readinessMu.Unlock()
|
||||
|
||||
readiness, err := h.syncService.GetReadiness()
|
||||
if err != nil && readiness == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
h.readinessMu.Lock()
|
||||
h.readinessCached = readiness
|
||||
h.readinessCachedAt = time.Now()
|
||||
h.readinessMu.Unlock()
|
||||
return readiness
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
}
|
||||
|
||||
// Load each page template with base
|
||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
|
||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html"}
|
||||
for _, page := range simplePages {
|
||||
pagePath := filepath.Join(templatesPath, page)
|
||||
var tmpl *template.Template
|
||||
@@ -197,10 +197,6 @@ func (h *WebHandler) ProjectDetail(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebHandler) AdminPricing(c *gin.Context) {
|
||||
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) Pricelists(c *gin.Context) {
|
||||
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user