refactor lot matching into shared module

This commit is contained in:
2026-02-07 06:22:56 +03:00
parent b629af9742
commit 95b5f8bf65
14 changed files with 1190 additions and 520 deletions

View File

@@ -5,7 +5,10 @@ import (
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
@@ -22,78 +25,159 @@ func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *
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"))
activeOnly := c.DefaultQuery("active_only", "false") == "true"
source := c.Query("source")
var (
pricelists any
total int64
err error
)
if activeOnly {
pricelists, total, err = h.service.ListActiveBySource(page, perPage, source)
} else {
pricelists, total, err = h.service.ListBySource(page, perPage, source)
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
}
isOffline := false
if v, ok := c.Get("is_offline"); ok {
if b, ok := v.(bool); ok {
isOffline = b
if source != "" {
filtered := localPLs[:0]
for _, lpl := range localPLs {
if strings.EqualFold(lpl.Source, source) {
filtered = append(filtered, lpl)
}
}
localPLs = filtered
}
// Fallback to local pricelists only in explicit offline mode.
if isOffline && total == 0 && h.localDB != nil {
localPLs, err := h.localDB.GetLocalPricelists()
if err == nil && len(localPLs) > 0 {
if source != "" {
filtered := localPLs[:0]
for _, lpl := range localPLs {
if lpl.Source == source {
filtered = append(filtered, lpl)
}
}
localPLs = filtered
}
// Convert to PricelistSummary format
summaries := make([]map[string]interface{}, len(localPLs))
for i, lpl := range localPLs {
summaries[i] = map[string]interface{}{
"id": lpl.ServerID,
"source": lpl.Source,
"version": lpl.Version,
"created_by": "sync",
"item_count": 0, // Not tracked
"usage_count": 0, // Not tracked in local
"is_active": true,
"created_at": lpl.CreatedAt,
"synced_from": "local",
}
}
c.JSON(http.StatusOK, gin.H{
"pricelists": summaries,
"total": len(summaries),
"page": page,
"per_page": perPage,
"offline": true,
})
return
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": pricelists,
"pricelists": summaries,
"total": total,
"page": page,
"per_page": perPage,
@@ -109,13 +193,22 @@ func (h *PricelistHandler) Get(c *gin.Context) {
return
}
pl, err := h.service.GetByID(uint(id))
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, pl)
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
@@ -161,6 +254,14 @@ func (h *PricelistHandler) Create(c *gin.Context) {
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)
}
@@ -223,10 +324,19 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
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": p.Current,
"current": current,
"total": p.Total,
"status": p.Status,
"status": status,
"message": p.Message,
"updated": p.Updated,
"errors": p.Errors,
@@ -243,6 +353,34 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
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,
@@ -275,6 +413,18 @@ func (h *PricelistHandler) Delete(c *gin.Context) {
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"})
}
@@ -309,6 +459,47 @@ func (h *PricelistHandler) SetActive(c *gin.Context) {
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})
}
@@ -325,20 +516,52 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
search := c.Query("search")
items, total, err := h.service.GetItems(uint(id), page, perPage, 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
}
pl, _ := h.service.GetByID(uint(id))
source := ""
if pl != nil {
source = pl.Source
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": source,
"items": items,
"source": localPL.Source,
"items": resultItems,
"total": total,
"page": page,
"per_page": perPage,
@@ -353,11 +576,21 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
return
}
lotNames, err := h.service.GetLotNames(uint(id))
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,
@@ -376,36 +609,19 @@ func (h *PricelistHandler) GetLatest(c *gin.Context) {
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
source = string(models.NormalizePricelistSource(source))
// Try to get from server first
pl, err := h.service.GetLatestActiveBySource(source)
localPL, err := h.localDB.GetLatestLocalPricelistBySource(source)
if err != nil {
// If offline or no server pricelists, try to get from local cache
if h.localDB == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
return
}
localPL, localErr := h.localDB.GetLatestLocalPricelistBySource(source)
if localErr != nil {
// No local pricelists either
c.JSON(http.StatusNotFound, gin.H{
"error": "no pricelists available",
"local_error": localErr.Error(),
})
return
}
// Return local pricelist
c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID,
"source": localPL.Source,
"version": localPL.Version,
"created_by": "sync",
"item_count": 0, // Not tracked in local pricelists
"is_active": true,
"created_at": localPL.CreatedAt,
"synced_from": "local",
})
c.JSON(http.StatusNotFound, gin.H{"error": "no pricelists available"})
return
}
c.JSON(http.StatusOK, pl)
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",
})
}