refactor lot matching into shared module
This commit is contained in:
@@ -5,7 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"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}
|
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
|
// List returns all pricelists with pagination
|
||||||
func (h *PricelistHandler) List(c *gin.Context) {
|
func (h *PricelistHandler) List(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
activeOnly := c.DefaultQuery("active_only", "false") == "true"
|
if page < 1 {
|
||||||
source := c.Query("source")
|
page = 1
|
||||||
|
|
||||||
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 perPage < 1 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
source := c.Query("source")
|
||||||
|
activeOnly := c.DefaultQuery("active_only", "false") == "true"
|
||||||
|
|
||||||
|
localPLs, err := h.localDB.GetLocalPricelists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isOffline := false
|
|
||||||
if v, ok := c.Get("is_offline"); ok {
|
|
||||||
if b, ok := v.(bool); ok {
|
|
||||||
isOffline = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 != "" {
|
if source != "" {
|
||||||
filtered := localPLs[:0]
|
filtered := localPLs[:0]
|
||||||
for _, lpl := range localPLs {
|
for _, lpl := range localPLs {
|
||||||
if lpl.Source == source {
|
if strings.EqualFold(lpl.Source, source) {
|
||||||
filtered = append(filtered, lpl)
|
filtered = append(filtered, lpl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
localPLs = filtered
|
localPLs = filtered
|
||||||
}
|
}
|
||||||
// Convert to PricelistSummary format
|
if activeOnly {
|
||||||
summaries := make([]map[string]interface{}, len(localPLs))
|
// Local cache stores only active snapshots for normal operations.
|
||||||
for i, lpl := range localPLs {
|
}
|
||||||
summaries[i] = map[string]interface{}{
|
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,
|
"id": lpl.ServerID,
|
||||||
"source": lpl.Source,
|
"source": lpl.Source,
|
||||||
"version": lpl.Version,
|
"version": lpl.Version,
|
||||||
"created_by": "sync",
|
"created_by": "sync",
|
||||||
"item_count": 0, // Not tracked
|
"item_count": itemCount,
|
||||||
"usage_count": 0, // Not tracked in local
|
"usage_count": usageCount,
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"created_at": lpl.CreatedAt,
|
"created_at": lpl.CreatedAt,
|
||||||
"synced_from": "local",
|
"synced_from": "local",
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"pricelists": summaries,
|
"pricelists": summaries,
|
||||||
"total": len(summaries),
|
|
||||||
"page": page,
|
|
||||||
"per_page": perPage,
|
|
||||||
"offline": true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"pricelists": pricelists,
|
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
@@ -109,13 +193,22 @@ func (h *PricelistHandler) Get(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pl, err := h.service.GetByID(uint(id))
|
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
|
||||||
return
|
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
|
// Create creates a new pricelist from current prices
|
||||||
@@ -161,6 +254,14 @@ func (h *PricelistHandler) Create(c *gin.Context) {
|
|||||||
return
|
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)
|
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": "Запуск..."})
|
sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."})
|
||||||
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, func(p pricelist.CreateProgress) {
|
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{
|
sendProgress(gin.H{
|
||||||
"current": p.Current,
|
"current": current,
|
||||||
"total": p.Total,
|
"total": p.Total,
|
||||||
"status": p.Status,
|
"status": status,
|
||||||
"message": p.Message,
|
"message": p.Message,
|
||||||
"updated": p.Updated,
|
"updated": p.Updated,
|
||||||
"errors": p.Errors,
|
"errors": p.Errors,
|
||||||
@@ -243,6 +353,34 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
|
|||||||
return
|
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{
|
sendProgress(gin.H{
|
||||||
"current": 4,
|
"current": 4,
|
||||||
"total": 4,
|
"total": 4,
|
||||||
@@ -275,6 +413,18 @@ func (h *PricelistHandler) Delete(c *gin.Context) {
|
|||||||
return
|
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"})
|
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +459,47 @@ func (h *PricelistHandler) SetActive(c *gin.Context) {
|
|||||||
return
|
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})
|
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"))
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
||||||
search := c.Query("search")
|
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 {
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pl, _ := h.service.GetByID(uint(id))
|
offset := (page - 1) * perPage
|
||||||
source := ""
|
|
||||||
if pl != nil {
|
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
|
||||||
source = pl.Source
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"source": source,
|
"source": localPL.Source,
|
||||||
"items": items,
|
"items": resultItems,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
@@ -353,11 +576,21 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"lot_names": lotNames,
|
"lot_names": lotNames,
|
||||||
@@ -376,36 +609,19 @@ func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
|||||||
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
|
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
|
||||||
source = string(models.NormalizePricelistSource(source))
|
source = string(models.NormalizePricelistSource(source))
|
||||||
|
|
||||||
// Try to get from server first
|
localPL, err := h.localDB.GetLatestLocalPricelistBySource(source)
|
||||||
pl, err := h.service.GetLatestActiveBySource(source)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If offline or no server pricelists, try to get from local cache
|
c.JSON(http.StatusNotFound, gin.H{"error": "no pricelists available"})
|
||||||
if h.localDB == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
|
|
||||||
return
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": localPL.ServerID,
|
"id": localPL.ServerID,
|
||||||
"source": localPL.Source,
|
"source": localPL.Source,
|
||||||
"version": localPL.Version,
|
"version": localPL.Version,
|
||||||
"created_by": "sync",
|
"created_by": "sync",
|
||||||
"item_count": 0, // Not tracked in local pricelists
|
"item_count": h.localDB.CountLocalPricelistItems(localPL.ID),
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"created_at": localPL.CreatedAt,
|
"created_at": localPL.CreatedAt,
|
||||||
"synced_from": "local",
|
"synced_from": "local",
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, pl)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -1296,41 +1297,11 @@ func (h *PricingHandler) ListLotsTable(c *gin.Context) {
|
|||||||
estimateMap[ec.Lot] = ec.Count
|
estimateMap[ec.Lot] = ec.Count
|
||||||
}
|
}
|
||||||
|
|
||||||
type stockRow struct {
|
stockQtyByLot, pnMap, err := warehouse.LoadLotMetrics(h.db, lotNames, true)
|
||||||
LotName string `gorm:"column:lot_name"`
|
if err != nil {
|
||||||
Qty *float64 `gorm:"column:total_qty"`
|
|
||||||
}
|
|
||||||
var stockRows []stockRow
|
|
||||||
if err := h.db.Raw(`
|
|
||||||
SELECT lp.lot_name, SUM(sl.qty) as total_qty
|
|
||||||
FROM stock_log sl
|
|
||||||
INNER JOIN lot_partnumbers lp ON LOWER(TRIM(lp.partnumber)) = LOWER(TRIM(sl.partnumber))
|
|
||||||
INNER JOIN (SELECT MAX(date) as max_date FROM stock_log) md ON sl.date = md.max_date
|
|
||||||
WHERE lp.lot_name IN ?
|
|
||||||
GROUP BY lp.lot_name
|
|
||||||
`, lotNames).Scan(&stockRows).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stockMap := make(map[string]*float64, len(stockRows))
|
|
||||||
for _, sr := range stockRows {
|
|
||||||
qty := sr.Qty
|
|
||||||
stockMap[sr.LotName] = qty
|
|
||||||
}
|
|
||||||
|
|
||||||
type pnRow struct {
|
|
||||||
LotName string `gorm:"column:lot_name"`
|
|
||||||
Partnumber string `gorm:"column:partnumber"`
|
|
||||||
}
|
|
||||||
var pnRows []pnRow
|
|
||||||
if err := h.db.Raw("SELECT lot_name, partnumber FROM lot_partnumbers WHERE lot_name IN ? ORDER BY lot_name, partnumber", lotNames).Scan(&pnRows).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pnMap := make(map[string][]string, len(pnRows))
|
|
||||||
for _, pn := range pnRows {
|
|
||||||
pnMap[pn.LotName] = append(pnMap[pn.LotName], pn.Partnumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]LotTableRow, len(rows))
|
result := make([]LotTableRow, len(rows))
|
||||||
for i, r := range rows {
|
for i, r := range rows {
|
||||||
@@ -1349,7 +1320,10 @@ func (h *PricingHandler) ListLotsTable(c *gin.Context) {
|
|||||||
Partnumbers: pnMap[r.LotName],
|
Partnumbers: pnMap[r.LotName],
|
||||||
Popularity: pop,
|
Popularity: pop,
|
||||||
EstimateCount: estimateMap[r.LotName],
|
EstimateCount: estimateMap[r.LotName],
|
||||||
StockQty: stockMap[r.LotName],
|
}
|
||||||
|
if qty, ok := stockQtyByLot[r.LotName]; ok {
|
||||||
|
q := qty
|
||||||
|
result[i].StockQty = &q
|
||||||
}
|
}
|
||||||
if result[i].Partnumbers == nil {
|
if result[i].Partnumbers == nil {
|
||||||
result[i].Partnumbers = []string{}
|
result[i].Partnumbers = []string{}
|
||||||
|
|||||||
@@ -164,20 +164,28 @@ func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
|
|||||||
|
|
||||||
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
|
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
|
||||||
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
|
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
|
||||||
|
partnumbers := make(LocalStringList, 0, len(item.Partnumbers))
|
||||||
|
partnumbers = append(partnumbers, item.Partnumbers...)
|
||||||
return &LocalPricelistItem{
|
return &LocalPricelistItem{
|
||||||
PricelistID: localPricelistID,
|
PricelistID: localPricelistID,
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
Price: item.Price,
|
Price: item.Price,
|
||||||
|
AvailableQty: item.AvailableQty,
|
||||||
|
Partnumbers: partnumbers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem
|
// LocalToPricelistItem converts LocalPricelistItem to models.PricelistItem
|
||||||
func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem {
|
func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *models.PricelistItem {
|
||||||
|
partnumbers := make([]string, 0, len(local.Partnumbers))
|
||||||
|
partnumbers = append(partnumbers, local.Partnumbers...)
|
||||||
return &models.PricelistItem{
|
return &models.PricelistItem{
|
||||||
ID: local.ID,
|
ID: local.ID,
|
||||||
PricelistID: serverPricelistID,
|
PricelistID: serverPricelistID,
|
||||||
LotName: local.LotName,
|
LotName: local.LotName,
|
||||||
Price: local.Price,
|
Price: local.Price,
|
||||||
|
AvailableQty: local.AvailableQty,
|
||||||
|
Partnumbers: partnumbers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,30 @@ func (c LocalConfigItems) Total() float64 {
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalStringList is a JSON-encoded list of strings stored as TEXT in SQLite.
|
||||||
|
type LocalStringList []string
|
||||||
|
|
||||||
|
func (s LocalStringList) Value() (driver.Value, error) {
|
||||||
|
return json.Marshal(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalStringList) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*s = make(LocalStringList, 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var bytes []byte
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
bytes = v
|
||||||
|
case string:
|
||||||
|
bytes = []byte(v)
|
||||||
|
default:
|
||||||
|
return errors.New("type assertion failed for LocalStringList")
|
||||||
|
}
|
||||||
|
return json.Unmarshal(bytes, s)
|
||||||
|
}
|
||||||
|
|
||||||
// LocalConfiguration stores configurations in local SQLite
|
// LocalConfiguration stores configurations in local SQLite
|
||||||
type LocalConfiguration struct {
|
type LocalConfiguration struct {
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
@@ -147,6 +171,8 @@ type LocalPricelistItem struct {
|
|||||||
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
||||||
LotName string `gorm:"not null" json:"lot_name"`
|
LotName string `gorm:"not null" json:"lot_name"`
|
||||||
Price float64 `gorm:"not null" json:"price"`
|
Price float64 `gorm:"not null" json:"price"`
|
||||||
|
AvailableQty *float64 `json:"available_qty,omitempty"`
|
||||||
|
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LocalPricelistItem) TableName() string {
|
func (LocalPricelistItem) TableName() string {
|
||||||
@@ -167,6 +193,33 @@ func (LocalComponent) TableName() string {
|
|||||||
return "local_components"
|
return "local_components"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalRemoteMigrationApplied tracks remote SQLite migrations received from server and applied locally.
|
||||||
|
type LocalRemoteMigrationApplied struct {
|
||||||
|
ID string `gorm:"primaryKey;size:128" json:"id"`
|
||||||
|
Checksum string `gorm:"size:128;not null" json:"checksum"`
|
||||||
|
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||||
|
AppliedAt time.Time `gorm:"not null" json:"applied_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LocalRemoteMigrationApplied) TableName() string {
|
||||||
|
return "local_remote_migrations_applied"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalSyncGuardState stores latest sync readiness decision for UI and preflight checks.
|
||||||
|
type LocalSyncGuardState struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Status string `gorm:"size:32;not null;index" json:"status"` // ready|blocked|unknown
|
||||||
|
ReasonCode string `gorm:"size:128" json:"reason_code,omitempty"`
|
||||||
|
ReasonText string `gorm:"type:text" json:"reason_text,omitempty"`
|
||||||
|
RequiredMinAppVersion *string `gorm:"size:64" json:"required_min_app_version,omitempty"`
|
||||||
|
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LocalSyncGuardState) TableName() string {
|
||||||
|
return "local_sync_guard_state"
|
||||||
|
}
|
||||||
|
|
||||||
// PendingChange stores changes that need to be synced to the server
|
// PendingChange stores changes that need to be synced to the server
|
||||||
type PendingChange struct {
|
type PendingChange struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
|||||||
238
internal/lotmatch/resolver.go
Normal file
238
internal/lotmatch/resolver.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package lotmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrResolveConflict = errors.New("multiple lot matches")
|
||||||
|
ErrResolveNotFound = errors.New("lot not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
type LotResolver struct {
|
||||||
|
partnumberToLots map[string][]string
|
||||||
|
exactLots map[string]string
|
||||||
|
allLots []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MappingMatcher struct {
|
||||||
|
exact map[string][]string
|
||||||
|
exactLot map[string]string
|
||||||
|
wildcard []wildcardMapping
|
||||||
|
}
|
||||||
|
|
||||||
|
type wildcardMapping struct {
|
||||||
|
lotName string
|
||||||
|
re *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLotResolverFromDB(db *gorm.DB) (*LotResolver, error) {
|
||||||
|
mappings, lots, err := loadMappingsAndLots(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return NewLotResolver(mappings, lots), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMappingMatcherFromDB(db *gorm.DB) (*MappingMatcher, error) {
|
||||||
|
mappings, lots, err := loadMappingsAndLots(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return NewMappingMatcher(mappings, lots), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLotResolver(mappings []models.LotPartnumber, lots []models.Lot) *LotResolver {
|
||||||
|
partnumberToLots := make(map[string][]string, len(mappings))
|
||||||
|
for _, m := range mappings {
|
||||||
|
pn := NormalizeKey(m.Partnumber)
|
||||||
|
lot := strings.TrimSpace(m.LotName)
|
||||||
|
if pn == "" || lot == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
partnumberToLots[pn] = append(partnumberToLots[pn], lot)
|
||||||
|
}
|
||||||
|
for key := range partnumberToLots {
|
||||||
|
partnumberToLots[key] = uniqueCaseInsensitive(partnumberToLots[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
exactLots := make(map[string]string, len(lots))
|
||||||
|
allLots := make([]string, 0, len(lots))
|
||||||
|
for _, l := range lots {
|
||||||
|
name := strings.TrimSpace(l.LotName)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exactLots[NormalizeKey(name)] = name
|
||||||
|
allLots = append(allLots, name)
|
||||||
|
}
|
||||||
|
sort.Slice(allLots, func(i, j int) bool {
|
||||||
|
li := len([]rune(allLots[i]))
|
||||||
|
lj := len([]rune(allLots[j]))
|
||||||
|
if li == lj {
|
||||||
|
return allLots[i] < allLots[j]
|
||||||
|
}
|
||||||
|
return li > lj
|
||||||
|
})
|
||||||
|
|
||||||
|
return &LotResolver{
|
||||||
|
partnumberToLots: partnumberToLots,
|
||||||
|
exactLots: exactLots,
|
||||||
|
allLots: allLots,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMappingMatcher(mappings []models.LotPartnumber, lots []models.Lot) *MappingMatcher {
|
||||||
|
exact := make(map[string][]string, len(mappings))
|
||||||
|
wildcards := make([]wildcardMapping, 0, len(mappings))
|
||||||
|
for _, m := range mappings {
|
||||||
|
pn := NormalizeKey(m.Partnumber)
|
||||||
|
lot := strings.TrimSpace(m.LotName)
|
||||||
|
if pn == "" || lot == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(pn, "*") {
|
||||||
|
pattern := "^" + regexp.QuoteMeta(pn) + "$"
|
||||||
|
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wildcards = append(wildcards, wildcardMapping{lotName: lot, re: re})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exact[pn] = append(exact[pn], lot)
|
||||||
|
}
|
||||||
|
for key := range exact {
|
||||||
|
exact[key] = uniqueCaseInsensitive(exact[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
exactLot := make(map[string]string, len(lots))
|
||||||
|
for _, l := range lots {
|
||||||
|
name := strings.TrimSpace(l.LotName)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exactLot[NormalizeKey(name)] = name
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MappingMatcher{
|
||||||
|
exact: exact,
|
||||||
|
exactLot: exactLot,
|
||||||
|
wildcard: wildcards,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LotResolver) Resolve(partnumber string) (string, string, error) {
|
||||||
|
key := NormalizeKey(partnumber)
|
||||||
|
if key == "" {
|
||||||
|
return "", "", ErrResolveNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
|
||||||
|
if len(mapped) == 1 {
|
||||||
|
return mapped[0], "mapping_table", nil
|
||||||
|
}
|
||||||
|
return "", "", ErrResolveConflict
|
||||||
|
}
|
||||||
|
if exact, ok := r.exactLots[key]; ok {
|
||||||
|
return exact, "article_exact", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
best := ""
|
||||||
|
bestLen := -1
|
||||||
|
tie := false
|
||||||
|
for _, lot := range r.allLots {
|
||||||
|
lotKey := NormalizeKey(lot)
|
||||||
|
if lotKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(key, lotKey) {
|
||||||
|
l := len([]rune(lotKey))
|
||||||
|
if l > bestLen {
|
||||||
|
best = lot
|
||||||
|
bestLen = l
|
||||||
|
tie = false
|
||||||
|
} else if l == bestLen && !strings.EqualFold(best, lot) {
|
||||||
|
tie = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == "" {
|
||||||
|
return "", "", ErrResolveNotFound
|
||||||
|
}
|
||||||
|
if tie {
|
||||||
|
return "", "", ErrResolveConflict
|
||||||
|
}
|
||||||
|
return best, "prefix", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MappingMatcher) MatchLots(partnumber string) []string {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := NormalizeKey(partnumber)
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lots := make([]string, 0, 2)
|
||||||
|
if exact := m.exact[key]; len(exact) > 0 {
|
||||||
|
lots = append(lots, exact...)
|
||||||
|
}
|
||||||
|
for _, wc := range m.wildcard {
|
||||||
|
if wc.re == nil || !wc.re.MatchString(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lots = append(lots, wc.lotName)
|
||||||
|
}
|
||||||
|
if lot, ok := m.exactLot[key]; ok && strings.TrimSpace(lot) != "" {
|
||||||
|
lots = append(lots, lot)
|
||||||
|
}
|
||||||
|
return uniqueCaseInsensitive(lots)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeKey(v string) string {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(v))
|
||||||
|
replacer := strings.NewReplacer(" ", "", "-", "", "_", "", ".", "", "/", "", "\\", "", "\"", "", "'", "", "(", "", ")", "")
|
||||||
|
return replacer.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMappingsAndLots(db *gorm.DB) ([]models.LotPartnumber, []models.Lot, error) {
|
||||||
|
var mappings []models.LotPartnumber
|
||||||
|
if err := db.Find(&mappings).Error; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var lots []models.Lot
|
||||||
|
if err := db.Select("lot_name").Find(&lots).Error; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return mappings, lots, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueCaseInsensitive(values []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
trimmed := strings.TrimSpace(v)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(trimmed)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return strings.ToLower(out[i]) < strings.ToLower(out[j])
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
62
internal/lotmatch/resolver_test.go
Normal file
62
internal/lotmatch/resolver_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package lotmatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLotResolverPrecedence(t *testing.T) {
|
||||||
|
resolver := NewLotResolver(
|
||||||
|
[]models.LotPartnumber{
|
||||||
|
{Partnumber: "PN-1", LotName: "LOT_A"},
|
||||||
|
},
|
||||||
|
[]models.Lot{
|
||||||
|
{LotName: "CPU_X_LONG"},
|
||||||
|
{LotName: "CPU_X"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
lot, by, err := resolver.Resolve("PN-1")
|
||||||
|
if err != nil || lot != "LOT_A" || by != "mapping_table" {
|
||||||
|
t.Fatalf("expected mapping_table LOT_A, got lot=%s by=%s err=%v", lot, by, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lot, by, err = resolver.Resolve("CPU_X")
|
||||||
|
if err != nil || lot != "CPU_X" || by != "article_exact" {
|
||||||
|
t.Fatalf("expected article_exact CPU_X, got lot=%s by=%s err=%v", lot, by, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lot, by, err = resolver.Resolve("CPU_X_LONG_001")
|
||||||
|
if err != nil || lot != "CPU_X_LONG" || by != "prefix" {
|
||||||
|
t.Fatalf("expected prefix CPU_X_LONG, got lot=%s by=%s err=%v", lot, by, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMappingMatcherWildcardAndExactLot(t *testing.T) {
|
||||||
|
matcher := NewMappingMatcher(
|
||||||
|
[]models.LotPartnumber{
|
||||||
|
{Partnumber: "R750*", LotName: "SERVER_R750"},
|
||||||
|
{Partnumber: "HDD-01", LotName: "HDD_01"},
|
||||||
|
},
|
||||||
|
[]models.Lot{
|
||||||
|
{LotName: "MEM_DDR5_16G_4800"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
check := func(partnumber string, want string) {
|
||||||
|
t.Helper()
|
||||||
|
got := matcher.MatchLots(partnumber)
|
||||||
|
if len(got) != 1 || got[0] != want {
|
||||||
|
t.Fatalf("partnumber %s: expected [%s], got %#v", partnumber, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check("R750XD", "SERVER_R750")
|
||||||
|
check("HDD-01", "HDD_01")
|
||||||
|
check("MEM_DDR5_16G_4800", "MEM_DDR5_16G_4800")
|
||||||
|
|
||||||
|
if got := matcher.MatchLots("UNKNOWN"); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no matches for UNKNOWN, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -288,60 +288,11 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lotSet := make(map[string]struct{}, len(lots))
|
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
|
||||||
for _, lot := range lots {
|
|
||||||
lotSet[lot] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolver, err := r.newWarehouseLotResolver()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var logs []struct {
|
|
||||||
Partnumber string `gorm:"column:partnumber"`
|
|
||||||
Qty *float64 `gorm:"column:qty"`
|
|
||||||
}
|
|
||||||
if err := r.db.Model(&models.StockLog{}).Select("partnumber, qty").Find(&logs).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
qtyByLot := make(map[string]float64, len(lots))
|
|
||||||
for _, row := range logs {
|
|
||||||
if row.Qty == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lot, err := resolver.resolve(row.Partnumber)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := lotSet[lot]; !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
qtyByLot[lot] += *row.Qty
|
|
||||||
}
|
|
||||||
|
|
||||||
var mappings []models.LotPartnumber
|
|
||||||
if err := r.db.Where("lot_name IN ? AND TRIM(lot_name) <> ''", lots).
|
|
||||||
Order("partnumber ASC").
|
|
||||||
Find(&mappings).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
partnumbersByLot := make(map[string][]string, len(lots))
|
|
||||||
seenPair := make(map[string]struct{}, len(mappings))
|
|
||||||
for _, m := range mappings {
|
|
||||||
lot := strings.TrimSpace(m.LotName)
|
|
||||||
pn := strings.TrimSpace(m.Partnumber)
|
|
||||||
if lot == "" || pn == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := lot + "\x00" + strings.ToLower(pn)
|
|
||||||
if _, ok := seenPair[key]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenPair[key] = struct{}{}
|
|
||||||
partnumbersByLot[lot] = append(partnumbersByLot[lot], pn)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range items {
|
for i := range items {
|
||||||
if qty, ok := qtyByLot[items[i].LotName]; ok {
|
if qty, ok := qtyByLot[items[i].LotName]; ok {
|
||||||
q := qty
|
q := qty
|
||||||
@@ -352,131 +303,6 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
|
||||||
errWarehouseResolveConflict = errors.New("multiple lot matches")
|
|
||||||
errWarehouseResolveNotFound = errors.New("lot not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
type warehouseLotResolver struct {
|
|
||||||
partnumberToLots map[string][]string
|
|
||||||
exactLots map[string]string
|
|
||||||
allLots []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *PricelistRepository) newWarehouseLotResolver() (*warehouseLotResolver, error) {
|
|
||||||
var mappings []models.LotPartnumber
|
|
||||||
if err := r.db.Find(&mappings).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
partnumberToLots := make(map[string][]string, len(mappings))
|
|
||||||
for _, m := range mappings {
|
|
||||||
pn := normalizeWarehouseResolverKey(m.Partnumber)
|
|
||||||
lot := strings.TrimSpace(m.LotName)
|
|
||||||
if pn == "" || lot == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
partnumberToLots[pn] = append(partnumberToLots[pn], lot)
|
|
||||||
}
|
|
||||||
for key, vals := range partnumberToLots {
|
|
||||||
partnumberToLots[key] = uniqueWarehouseStrings(vals)
|
|
||||||
}
|
|
||||||
|
|
||||||
var allLotsRows []models.Lot
|
|
||||||
if err := r.db.Select("lot_name").Find(&allLotsRows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
exactLots := make(map[string]string, len(allLotsRows))
|
|
||||||
allLots := make([]string, 0, len(allLotsRows))
|
|
||||||
for _, row := range allLotsRows {
|
|
||||||
lot := strings.TrimSpace(row.LotName)
|
|
||||||
if lot == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
exactLots[normalizeWarehouseResolverKey(lot)] = lot
|
|
||||||
allLots = append(allLots, lot)
|
|
||||||
}
|
|
||||||
sort.Slice(allLots, func(i, j int) bool {
|
|
||||||
li := len([]rune(allLots[i]))
|
|
||||||
lj := len([]rune(allLots[j]))
|
|
||||||
if li == lj {
|
|
||||||
return allLots[i] < allLots[j]
|
|
||||||
}
|
|
||||||
return li > lj
|
|
||||||
})
|
|
||||||
|
|
||||||
return &warehouseLotResolver{
|
|
||||||
partnumberToLots: partnumberToLots,
|
|
||||||
exactLots: exactLots,
|
|
||||||
allLots: allLots,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *warehouseLotResolver) resolve(partnumber string) (string, error) {
|
|
||||||
key := normalizeWarehouseResolverKey(partnumber)
|
|
||||||
if key == "" {
|
|
||||||
return "", errWarehouseResolveNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
|
|
||||||
if len(mapped) == 1 {
|
|
||||||
return mapped[0], nil
|
|
||||||
}
|
|
||||||
return "", errWarehouseResolveConflict
|
|
||||||
}
|
|
||||||
if exact, ok := r.exactLots[key]; ok {
|
|
||||||
return exact, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
best := ""
|
|
||||||
bestLen := -1
|
|
||||||
tie := false
|
|
||||||
for _, lot := range r.allLots {
|
|
||||||
lotKey := normalizeWarehouseResolverKey(lot)
|
|
||||||
if lotKey == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(key, lotKey) {
|
|
||||||
l := len([]rune(lotKey))
|
|
||||||
if l > bestLen {
|
|
||||||
best = lot
|
|
||||||
bestLen = l
|
|
||||||
tie = false
|
|
||||||
} else if l == bestLen && !strings.EqualFold(best, lot) {
|
|
||||||
tie = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if best == "" {
|
|
||||||
return "", errWarehouseResolveNotFound
|
|
||||||
}
|
|
||||||
if tie {
|
|
||||||
return "", errWarehouseResolveConflict
|
|
||||||
}
|
|
||||||
return best, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeWarehouseResolverKey(v string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
func uniqueWarehouseStrings(values []string) []string {
|
|
||||||
seen := make(map[string]struct{}, len(values))
|
|
||||||
out := make([]string, 0, len(values))
|
|
||||||
for _, v := range values {
|
|
||||||
n := strings.TrimSpace(v)
|
|
||||||
if n == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
k := strings.ToLower(n)
|
|
||||||
if _, ok := seen[k]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[k] = struct{}{}
|
|
||||||
out = append(out, n)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPriceForLot returns item price for a lot within a pricelist.
|
// GetPriceForLot returns item price for a lot within a pricelist.
|
||||||
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||||
var item models.PricelistItem
|
var item models.PricelistItem
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,6 +133,22 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt
|
|||||||
}
|
}
|
||||||
|
|
||||||
items := make([]models.PricelistItem, 0)
|
items := make([]models.PricelistItem, 0)
|
||||||
|
if len(sourceItems) == 0 && source == string(models.PricelistSourceWarehouse) {
|
||||||
|
warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.repo.Delete(pricelist.ID)
|
||||||
|
return nil, fmt.Errorf("building warehouse pricelist from stock_log: %w", err)
|
||||||
|
}
|
||||||
|
sourceItems = make([]CreateItemInput, 0, len(warehouseItems))
|
||||||
|
for _, item := range warehouseItems {
|
||||||
|
sourceItems = append(sourceItems, CreateItemInput{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Price: item.Price,
|
||||||
|
PriceMethod: item.PriceMethod,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(sourceItems) > 0 {
|
if len(sourceItems) > 0 {
|
||||||
items = make([]models.PricelistItem, 0, len(sourceItems))
|
items = make([]models.PricelistItem, 0, len(sourceItems))
|
||||||
for _, srcItem := range sourceItems {
|
for _, srcItem := range sourceItems {
|
||||||
|
|||||||
72
internal/services/pricelist/service_warehouse_test.go
Normal file
72
internal/services/pricelist/service_warehouse_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package pricelist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateWarehousePricelistFromStockLog(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&models.Pricelist{},
|
||||||
|
&models.PricelistItem{},
|
||||||
|
&models.StockLog{},
|
||||||
|
&models.Lot{},
|
||||||
|
&models.LotPartnumber{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("automigrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&models.Lot{LotName: "CPU_X", LotDescription: "CPU"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed lot: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-CPU-X", LotName: "CPU_X"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed mapping: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
qty1 := 2.0
|
||||||
|
qty2 := 8.0
|
||||||
|
now := time.Now()
|
||||||
|
rows := []models.StockLog{
|
||||||
|
{Partnumber: "PN-CPU-X", Date: now, Price: 100, Qty: &qty1},
|
||||||
|
{Partnumber: "PN-CPU-X", Date: now, Price: 200, Qty: &qty2},
|
||||||
|
}
|
||||||
|
if err := db.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed stock log: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := repository.NewPricelistRepository(db)
|
||||||
|
svc := NewService(db, repo, nil, nil)
|
||||||
|
|
||||||
|
pl, err := svc.CreateForSourceWithProgress("tester", string(models.PricelistSourceWarehouse), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create warehouse pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if pl.Source != string(models.PricelistSourceWarehouse) {
|
||||||
|
t.Fatalf("unexpected source: %s", pl.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []models.PricelistItem
|
||||||
|
if err := db.Where("pricelist_id = ?", pl.ID).Find(&items).Error; err != nil {
|
||||||
|
t.Fatalf("load pricelist items: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("expected 1 item, got %d", len(items))
|
||||||
|
}
|
||||||
|
if items[0].LotName != "CPU_X" {
|
||||||
|
t.Fatalf("unexpected lot name: %s", items[0].LotName)
|
||||||
|
}
|
||||||
|
if math.Abs(items[0].Price-200) > 0.001 {
|
||||||
|
t.Fatalf("expected weighted median price 200, got %f", items[0].Price)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -14,8 +13,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
pricelistsvc "git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
pricelistsvc "git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
@@ -137,7 +138,7 @@ func (s *StockImportService) Import(
|
|||||||
Total: 100,
|
Total: 100,
|
||||||
})
|
})
|
||||||
|
|
||||||
partnumberMappings, err := s.loadPartnumberMappings()
|
partnumberMatcher, err := lotmatch.NewMappingMatcherFromDB(s.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -173,7 +174,7 @@ func (s *StockImportService) Import(
|
|||||||
}
|
}
|
||||||
partnumber := strings.TrimSpace(row.Article)
|
partnumber := strings.TrimSpace(row.Article)
|
||||||
key := normalizeKey(partnumber)
|
key := normalizeKey(partnumber)
|
||||||
mappedLots := partnumberMappings[key]
|
mappedLots := partnumberMatcher.MatchLots(partnumber)
|
||||||
if len(mappedLots) == 0 {
|
if len(mappedLots) == 0 {
|
||||||
unmapped++
|
unmapped++
|
||||||
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
|
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
|
||||||
@@ -342,74 +343,22 @@ func (s *StockImportService) replaceStockLogs(records []models.StockLog) (int64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) {
|
func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) {
|
||||||
var logs []models.StockLog
|
warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db)
|
||||||
if err := s.db.Select("partnumber, price, qty").Where("price > 0").Find(&logs).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resolver, err := s.newLotResolver()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
grouped := make(map[string][]weightedPricePoint)
|
items := make([]pricelistsvc.CreateItemInput, 0, len(warehouseItems))
|
||||||
for _, l := range logs {
|
for _, item := range warehouseItems {
|
||||||
partnumber := strings.TrimSpace(l.Partnumber)
|
|
||||||
if partnumber == "" || l.Price <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lotName, _, err := resolver.resolve(partnumber)
|
|
||||||
if err != nil || strings.TrimSpace(lotName) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
weight := 0.0
|
|
||||||
if l.Qty != nil && *l.Qty > 0 {
|
|
||||||
weight = *l.Qty
|
|
||||||
}
|
|
||||||
grouped[lotName] = append(grouped[lotName], weightedPricePoint{
|
|
||||||
price: l.Price,
|
|
||||||
weight: weight,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]pricelistsvc.CreateItemInput, 0, len(grouped))
|
|
||||||
for lot, values := range grouped {
|
|
||||||
price := weightedMedian(values)
|
|
||||||
if price <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, pricelistsvc.CreateItemInput{
|
items = append(items, pricelistsvc.CreateItemInput{
|
||||||
LotName: lot,
|
LotName: item.LotName,
|
||||||
Price: price,
|
Price: item.Price,
|
||||||
PriceMethod: "weighted_median",
|
PriceMethod: item.PriceMethod,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
return items[i].LotName < items[j].LotName
|
|
||||||
})
|
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StockImportService) loadPartnumberMappings() (map[string][]string, error) {
|
|
||||||
var mappings []models.LotPartnumber
|
|
||||||
if err := s.db.Find(&mappings).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
partnumberToLots := make(map[string][]string, len(mappings))
|
|
||||||
for _, m := range mappings {
|
|
||||||
pn := normalizeKey(m.Partnumber)
|
|
||||||
lot := strings.TrimSpace(m.LotName)
|
|
||||||
if pn == "" || lot == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
partnumberToLots[pn] = append(partnumberToLots[pn], lot)
|
|
||||||
}
|
|
||||||
for key, lots := range partnumberToLots {
|
|
||||||
partnumberToLots[key] = uniqueStrings(lots)
|
|
||||||
}
|
|
||||||
return partnumberToLots, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func upsertSuggestion(prev StockMappingSuggestion, candidate StockMappingSuggestion) StockMappingSuggestion {
|
func upsertSuggestion(prev StockMappingSuggestion, candidate StockMappingSuggestion) StockMappingSuggestion {
|
||||||
if strings.TrimSpace(prev.Partnumber) == "" {
|
if strings.TrimSpace(prev.Partnumber) == "" {
|
||||||
return candidate
|
return candidate
|
||||||
@@ -677,8 +626,6 @@ var (
|
|||||||
reISODate = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`)
|
reISODate = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`)
|
||||||
reRuDate = regexp.MustCompile(`\b([0-3]\d)\.([01]\d)\.(20\d{2})\b`)
|
reRuDate = regexp.MustCompile(`\b([0-3]\d)\.([01]\d)\.(20\d{2})\b`)
|
||||||
mxlCellRe = regexp.MustCompile(`\{16,\d+,\s*\{1,1,\s*\{"ru","(.*?)"\}\s*\},0\},(\d+),`)
|
mxlCellRe = regexp.MustCompile(`\{16,\d+,\s*\{1,1,\s*\{"ru","(.*?)"\}\s*\},0\},(\d+),`)
|
||||||
errResolveConflict = errors.New("multiple lot matches")
|
|
||||||
errResolveNotFound = errors.New("lot not found")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func parseStockRows(filename string, content []byte) ([]stockImportRow, error) {
|
func parseStockRows(filename string, content []byte) ([]stockImportRow, error) {
|
||||||
@@ -1020,124 +967,8 @@ func weightedMedian(values []weightedPricePoint) float64 {
|
|||||||
return items[len(items)-1].price
|
return items[len(items)-1].price
|
||||||
}
|
}
|
||||||
|
|
||||||
type lotResolver struct {
|
|
||||||
partnumberToLots map[string][]string
|
|
||||||
exactLots map[string]string
|
|
||||||
allLots []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StockImportService) newLotResolver() (*lotResolver, error) {
|
|
||||||
var mappings []models.LotPartnumber
|
|
||||||
if err := s.db.Find(&mappings).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
partnumberToLots := make(map[string][]string, len(mappings))
|
|
||||||
for _, m := range mappings {
|
|
||||||
p := normalizeKey(m.Partnumber)
|
|
||||||
if p == "" || strings.TrimSpace(m.LotName) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
partnumberToLots[p] = append(partnumberToLots[p], m.LotName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lots []models.Lot
|
|
||||||
if err := s.db.Select("lot_name").Find(&lots).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
exactLots := make(map[string]string, len(lots))
|
|
||||||
allLots := make([]string, 0, len(lots))
|
|
||||||
for _, l := range lots {
|
|
||||||
name := strings.TrimSpace(l.LotName)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
k := normalizeKey(name)
|
|
||||||
exactLots[k] = name
|
|
||||||
allLots = append(allLots, name)
|
|
||||||
}
|
|
||||||
sort.Slice(allLots, func(i, j int) bool {
|
|
||||||
li := len([]rune(allLots[i]))
|
|
||||||
lj := len([]rune(allLots[j]))
|
|
||||||
if li == lj {
|
|
||||||
return allLots[i] < allLots[j]
|
|
||||||
}
|
|
||||||
return li > lj
|
|
||||||
})
|
|
||||||
|
|
||||||
return &lotResolver{
|
|
||||||
partnumberToLots: partnumberToLots,
|
|
||||||
exactLots: exactLots,
|
|
||||||
allLots: allLots,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *lotResolver) resolve(article string) (string, string, error) {
|
|
||||||
key := normalizeKey(article)
|
|
||||||
if key == "" {
|
|
||||||
return "", "", errResolveNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
|
|
||||||
uniq := uniqueStrings(mapped)
|
|
||||||
if len(uniq) == 1 {
|
|
||||||
return uniq[0], "mapping_table", nil
|
|
||||||
}
|
|
||||||
return "", "", errResolveConflict
|
|
||||||
}
|
|
||||||
|
|
||||||
if lot, ok := r.exactLots[key]; ok {
|
|
||||||
return lot, "article_exact", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
best := ""
|
|
||||||
bestLen := -1
|
|
||||||
tie := false
|
|
||||||
for _, lot := range r.allLots {
|
|
||||||
lotKey := normalizeKey(lot)
|
|
||||||
if lotKey == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(key, lotKey) {
|
|
||||||
l := len([]rune(lotKey))
|
|
||||||
if l > bestLen {
|
|
||||||
best = lot
|
|
||||||
bestLen = l
|
|
||||||
tie = false
|
|
||||||
} else if l == bestLen && !strings.EqualFold(best, lot) {
|
|
||||||
tie = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if best == "" {
|
|
||||||
return "", "", errResolveNotFound
|
|
||||||
}
|
|
||||||
if tie {
|
|
||||||
return "", "", errResolveConflict
|
|
||||||
}
|
|
||||||
return best, "prefix", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeKey(v string) string {
|
func normalizeKey(v string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(v))
|
return lotmatch.NormalizeKey(v)
|
||||||
}
|
|
||||||
|
|
||||||
func uniqueStrings(values []string) []string {
|
|
||||||
seen := make(map[string]bool, len(values))
|
|
||||||
out := make([]string, 0, len(values))
|
|
||||||
for _, v := range values {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if v == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
k := strings.ToLower(v)
|
|
||||||
if seen[k] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[k] = true
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readZipFile(zr *zip.Reader, name string) ([]byte, error) {
|
func readZipFile(zr *zip.Reader, name string) ([]byte, error) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -99,39 +100,42 @@ func TestParseXLSXRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLotResolverPrecedenceAndConflicts(t *testing.T) {
|
func TestLotResolverPrecedenceAndConflicts(t *testing.T) {
|
||||||
r := &lotResolver{
|
resolver := lotmatch.NewLotResolver(
|
||||||
partnumberToLots: map[string][]string{
|
[]models.LotPartnumber{
|
||||||
"pn-1": {"LOT_MAPPED"},
|
{Partnumber: "pn-1", LotName: "LOT_MAPPED"},
|
||||||
"pn-conflict": {"LOT_A", "LOT_B"},
|
{Partnumber: "pn-conflict", LotName: "LOT_A"},
|
||||||
|
{Partnumber: "pn-conflict", LotName: "LOT_B"},
|
||||||
},
|
},
|
||||||
exactLots: map[string]string{
|
[]models.Lot{
|
||||||
"cpu_a": "CPU_A",
|
{LotName: "CPU_A_LONG"},
|
||||||
|
{LotName: "CPU_A"},
|
||||||
|
{LotName: "ABC "},
|
||||||
|
{LotName: "ABC\t"},
|
||||||
},
|
},
|
||||||
allLots: []string{"CPU_A_LONG", "CPU_A", "ABC ", "ABC\t"},
|
)
|
||||||
}
|
|
||||||
|
|
||||||
lot, typ, err := r.resolve("pn-1")
|
lot, typ, err := resolver.Resolve("pn-1")
|
||||||
if err != nil || lot != "LOT_MAPPED" || typ != "mapping_table" {
|
if err != nil || lot != "LOT_MAPPED" || typ != "mapping_table" {
|
||||||
t.Fatalf("mapping_table mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
t.Fatalf("mapping_table mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
lot, typ, err = r.resolve("cpu_a")
|
lot, typ, err = resolver.Resolve("cpu_a")
|
||||||
if err != nil || lot != "CPU_A" || typ != "article_exact" {
|
if err != nil || lot != "CPU_A" || typ != "article_exact" {
|
||||||
t.Fatalf("article_exact mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
t.Fatalf("article_exact mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
lot, typ, err = r.resolve("cpu_a_long_suffix")
|
lot, typ, err = resolver.Resolve("cpu_a_long_suffix")
|
||||||
if err != nil || lot != "CPU_A_LONG" || typ != "prefix" {
|
if err != nil || lot != "CPU_A_LONG" || typ != "prefix" {
|
||||||
t.Fatalf("prefix mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
t.Fatalf("prefix mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = r.resolve("abx")
|
_, _, err = resolver.Resolve("abx")
|
||||||
if err == nil {
|
if err == nil || err != lotmatch.ErrResolveNotFound {
|
||||||
t.Fatalf("expected not found error")
|
t.Fatalf("expected not found error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = r.resolve("pn-conflict")
|
_, _, err = resolver.Resolve("pn-conflict")
|
||||||
if err == nil || err != errResolveConflict {
|
if err == nil || err != lotmatch.ErrResolveConflict {
|
||||||
t.Fatalf("expected conflict, got %v", err)
|
t.Fatalf("expected conflict, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +271,42 @@ func TestBuildWarehousePricelistItems_UsesPrefixResolver(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPartnumberMappings_WildcardMatch(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
if err := db.AutoMigrate(&models.LotPartnumber{}, &models.Lot{}); err != nil {
|
||||||
|
t.Fatalf("automigrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mappings := []models.LotPartnumber{
|
||||||
|
{Partnumber: "R750*", LotName: "SERVER_R750"},
|
||||||
|
{Partnumber: "HDD-01", LotName: "HDD_01"},
|
||||||
|
}
|
||||||
|
if err := db.Create(&mappings).Error; err != nil {
|
||||||
|
t.Fatalf("seed mappings: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.Lot{LotName: "MEM_DDR5_16G_4800"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed lot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver, err := lotmatch.NewMappingMatcherFromDB(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewMappingMatcherFromDB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := resolver.MatchLots("R750XD"); len(got) != 1 || got[0] != "SERVER_R750" {
|
||||||
|
t.Fatalf("expected wildcard match SERVER_R750, got %#v", got)
|
||||||
|
}
|
||||||
|
if got := resolver.MatchLots("HDD-01"); len(got) != 1 || got[0] != "HDD_01" {
|
||||||
|
t.Fatalf("expected exact match HDD_01, got %#v", got)
|
||||||
|
}
|
||||||
|
if got := resolver.MatchLots("UNKNOWN"); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no matches, got %#v", got)
|
||||||
|
}
|
||||||
|
if got := resolver.MatchLots("MEM_DDR5_16G_4800"); len(got) != 1 || got[0] != "MEM_DDR5_16G_4800" {
|
||||||
|
t.Fatalf("expected exact lot fallback, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func openTestDB(t *testing.T) *gorm.DB {
|
func openTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
|||||||
@@ -322,6 +322,9 @@ func (s *Service) NeedSync() (bool, error) {
|
|||||||
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
// SyncPricelists synchronizes all active pricelists from server to local SQLite
|
||||||
func (s *Service) SyncPricelists() (int, error) {
|
func (s *Service) SyncPricelists() (int, error) {
|
||||||
slog.Info("starting pricelist sync")
|
slog.Info("starting pricelist sync")
|
||||||
|
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
// Get database connection
|
// Get database connection
|
||||||
mariaDB, err := s.getDB()
|
mariaDB, err := s.getDB()
|
||||||
@@ -592,10 +595,14 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|||||||
// Convert and save locally
|
// Convert and save locally
|
||||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||||
for i, item := range serverItems {
|
for i, item := range serverItems {
|
||||||
|
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
|
||||||
|
partnumbers = append(partnumbers, item.Partnumbers...)
|
||||||
localItems[i] = localdb.LocalPricelistItem{
|
localItems[i] = localdb.LocalPricelistItem{
|
||||||
PricelistID: localPricelistID,
|
PricelistID: localPricelistID,
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
Price: item.Price,
|
Price: item.Price,
|
||||||
|
AvailableQty: item.AvailableQty,
|
||||||
|
Partnumbers: partnumbers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,6 +679,10 @@ func (s *Service) SyncPricelistsIfNeeded() error {
|
|||||||
|
|
||||||
// PushPendingChanges pushes all pending changes to the server
|
// PushPendingChanges pushes all pending changes to the server
|
||||||
func (s *Service) PushPendingChanges() (int, error) {
|
func (s *Service) PushPendingChanges() (int, error) {
|
||||||
|
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
removed, err := s.localDB.PurgeOrphanConfigurationPendingChanges()
|
removed, err := s.localDB.PurgeOrphanConfigurationPendingChanges()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to purge orphan configuration pending changes", "error", err)
|
slog.Warn("failed to purge orphan configuration pending changes", "error", err)
|
||||||
|
|||||||
219
internal/warehouse/snapshot.go
Normal file
219
internal/warehouse/snapshot.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package warehouse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SnapshotItem struct {
|
||||||
|
LotName string
|
||||||
|
Price float64
|
||||||
|
PriceMethod string
|
||||||
|
}
|
||||||
|
|
||||||
|
type weightedPricePoint struct {
|
||||||
|
price float64
|
||||||
|
weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputePricelistItemsFromStockLog builds warehouse snapshot items from stock_log.
|
||||||
|
func ComputePricelistItemsFromStockLog(db *gorm.DB) ([]SnapshotItem, error) {
|
||||||
|
type stockRow struct {
|
||||||
|
Partnumber string `gorm:"column:partnumber"`
|
||||||
|
Price float64 `gorm:"column:price"`
|
||||||
|
Qty *float64 `gorm:"column:qty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []stockRow
|
||||||
|
if err := db.Table(models.StockLog{}.TableName()).Select("partnumber, price, qty").Where("price > 0").Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver, err := lotmatch.NewLotResolverFromDB(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped := make(map[string][]weightedPricePoint)
|
||||||
|
for _, row := range rows {
|
||||||
|
pn := strings.TrimSpace(row.Partnumber)
|
||||||
|
if pn == "" || row.Price <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lot, _, err := resolver.Resolve(pn)
|
||||||
|
if err != nil || strings.TrimSpace(lot) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weight := 0.0
|
||||||
|
if row.Qty != nil && *row.Qty > 0 {
|
||||||
|
weight = *row.Qty
|
||||||
|
}
|
||||||
|
grouped[lot] = append(grouped[lot], weightedPricePoint{price: row.Price, weight: weight})
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]SnapshotItem, 0, len(grouped))
|
||||||
|
for lot, values := range grouped {
|
||||||
|
price := weightedMedian(values)
|
||||||
|
if price <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, SnapshotItem{LotName: lot, Price: price, PriceMethod: "weighted_median"})
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool { return items[i].LotName < items[j].LotName })
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadLotMetrics returns stock qty and partnumbers for selected lots.
|
||||||
|
// If latestOnly is true, qty/partnumbers from stock_log are calculated only for latest import date.
|
||||||
|
func LoadLotMetrics(db *gorm.DB, lotNames []string, latestOnly bool) (map[string]float64, map[string][]string, error) {
|
||||||
|
qtyByLot := make(map[string]float64, len(lotNames))
|
||||||
|
partnumbersByLot := make(map[string][]string, len(lotNames))
|
||||||
|
if len(lotNames) == 0 {
|
||||||
|
return qtyByLot, partnumbersByLot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lotSet := make(map[string]struct{}, len(lotNames))
|
||||||
|
for _, lot := range lotNames {
|
||||||
|
trimmed := strings.TrimSpace(lot)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lotSet[trimmed] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver, err := lotmatch.NewLotResolverFromDB(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seenPN := make(map[string]map[string]struct{}, len(lotSet))
|
||||||
|
addPartnumber := func(lotName, partnumber string) {
|
||||||
|
lotName = strings.TrimSpace(lotName)
|
||||||
|
partnumber = strings.TrimSpace(partnumber)
|
||||||
|
if lotName == "" || partnumber == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := lotSet[lotName]; !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seenPN[lotName]; !ok {
|
||||||
|
seenPN[lotName] = map[string]struct{}{}
|
||||||
|
}
|
||||||
|
key := strings.ToLower(partnumber)
|
||||||
|
if _, ok := seenPN[lotName][key]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seenPN[lotName][key] = struct{}{}
|
||||||
|
partnumbersByLot[lotName] = append(partnumbersByLot[lotName], partnumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mappingRows []models.LotPartnumber
|
||||||
|
if err := db.Select("partnumber, lot_name").Find(&mappingRows).Error; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, row := range mappingRows {
|
||||||
|
addPartnumber(row.LotName, row.Partnumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
type stockRow struct {
|
||||||
|
Partnumber string `gorm:"column:partnumber"`
|
||||||
|
Qty *float64 `gorm:"column:qty"`
|
||||||
|
}
|
||||||
|
var stockRows []stockRow
|
||||||
|
if latestOnly {
|
||||||
|
err = db.Raw(`
|
||||||
|
SELECT sl.partnumber, sl.qty
|
||||||
|
FROM stock_log sl
|
||||||
|
INNER JOIN (SELECT MAX(date) AS max_date FROM stock_log) md ON sl.date = md.max_date
|
||||||
|
`).Scan(&stockRows).Error
|
||||||
|
} else {
|
||||||
|
err = db.Table(models.StockLog{}.TableName()).Select("partnumber, qty").Scan(&stockRows).Error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range stockRows {
|
||||||
|
pn := strings.TrimSpace(row.Partnumber)
|
||||||
|
if pn == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lot, _, err := resolver.Resolve(pn)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := lotSet[lot]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if row.Qty != nil {
|
||||||
|
qtyByLot[lot] += *row.Qty
|
||||||
|
}
|
||||||
|
addPartnumber(lot, pn)
|
||||||
|
}
|
||||||
|
|
||||||
|
for lot := range partnumbersByLot {
|
||||||
|
sort.Slice(partnumbersByLot[lot], func(i, j int) bool {
|
||||||
|
return strings.ToLower(partnumbersByLot[lot][i]) < strings.ToLower(partnumbersByLot[lot][j])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return qtyByLot, partnumbersByLot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightedMedian(values []weightedPricePoint) float64 {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
type pair struct {
|
||||||
|
price float64
|
||||||
|
weight float64
|
||||||
|
}
|
||||||
|
items := make([]pair, 0, len(values))
|
||||||
|
totalWeight := 0.0
|
||||||
|
prices := make([]float64, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
if v.price <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prices = append(prices, v.price)
|
||||||
|
if v.weight > 0 {
|
||||||
|
items = append(items, pair{price: v.price, weight: v.weight})
|
||||||
|
totalWeight += v.weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if totalWeight <= 0 {
|
||||||
|
return median(prices)
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].price == items[j].price {
|
||||||
|
return items[i].weight < items[j].weight
|
||||||
|
}
|
||||||
|
return items[i].price < items[j].price
|
||||||
|
})
|
||||||
|
threshold := totalWeight / 2.0
|
||||||
|
acc := 0.0
|
||||||
|
for _, it := range items {
|
||||||
|
acc += it.weight
|
||||||
|
if acc >= threshold {
|
||||||
|
return it.price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items[len(items)-1].price
|
||||||
|
}
|
||||||
|
|
||||||
|
func median(values []float64) float64 {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
cp := append([]float64(nil), values...)
|
||||||
|
sort.Float64s(cp)
|
||||||
|
n := len(cp)
|
||||||
|
if n%2 == 0 {
|
||||||
|
return (cp[n/2-1] + cp[n/2]) / 2
|
||||||
|
}
|
||||||
|
return cp[n/2]
|
||||||
|
}
|
||||||
103
internal/warehouse/snapshot_test.go
Normal file
103
internal/warehouse/snapshot_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package warehouse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputePricelistItemsFromStockLog(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
if err := db.AutoMigrate(&models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
|
||||||
|
t.Fatalf("automigrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&models.Lot{LotName: "CPU_X"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed lot: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-CPU-X", LotName: "CPU_X"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed mapping: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
qtySmall := 1.0
|
||||||
|
qtyBig := 9.0
|
||||||
|
now := time.Now()
|
||||||
|
rows := []models.StockLog{
|
||||||
|
{Partnumber: "PN CPU X", Date: now, Price: 100, Qty: &qtySmall},
|
||||||
|
{Partnumber: "CPU_X-EXTRA", Date: now, Price: 200, Qty: &qtyBig},
|
||||||
|
}
|
||||||
|
if err := db.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed stock rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := ComputePricelistItemsFromStockLog(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ComputePricelistItemsFromStockLog: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("expected 1 item, got %d", len(items))
|
||||||
|
}
|
||||||
|
if items[0].LotName != "CPU_X" {
|
||||||
|
t.Fatalf("expected lot CPU_X, got %s", items[0].LotName)
|
||||||
|
}
|
||||||
|
if math.Abs(items[0].Price-200) > 0.001 {
|
||||||
|
t.Fatalf("expected weighted median 200, got %f", items[0].Price)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadLotMetricsLatestOnlyIncludesPartnumbers(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
if err := db.AutoMigrate(&models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
|
||||||
|
t.Fatalf("automigrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&models.Lot{LotName: "CPU_X"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed lot: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-MAPPED", LotName: "CPU_X"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed mapping: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
newDate := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
|
||||||
|
oldQty := 10.0
|
||||||
|
newQty := 3.0
|
||||||
|
rows := []models.StockLog{
|
||||||
|
{Partnumber: "CPU_X-001", Date: oldDate, Price: 100, Qty: &oldQty},
|
||||||
|
{Partnumber: "CPU_X-001", Date: newDate, Price: 100, Qty: &newQty},
|
||||||
|
}
|
||||||
|
if err := db.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed stock rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
qtyByLot, pnsByLot, err := LoadLotMetrics(db, []string{"CPU_X"}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLotMetrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := qtyByLot["CPU_X"]; math.Abs(got-3.0) > 0.001 {
|
||||||
|
t.Fatalf("expected latest qty 3, got %f", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
pns := pnsByLot["CPU_X"]
|
||||||
|
if !slices.Contains(pns, "PN-MAPPED") {
|
||||||
|
t.Fatalf("expected mapped PN-MAPPED in partnumbers, got %v", pns)
|
||||||
|
}
|
||||||
|
if !slices.Contains(pns, "CPU_X-001") {
|
||||||
|
t.Fatalf("expected stock CPU_X-001 in partnumbers, got %v", pns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user