refactor lot matching into shared module
This commit is contained in:
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -1296,41 +1297,11 @@ func (h *PricingHandler) ListLotsTable(c *gin.Context) {
|
||||
estimateMap[ec.Lot] = ec.Count
|
||||
}
|
||||
|
||||
type stockRow struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
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 {
|
||||
stockQtyByLot, pnMap, err := warehouse.LoadLotMetrics(h.db, lotNames, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
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))
|
||||
for i, r := range rows {
|
||||
@@ -1349,7 +1320,10 @@ func (h *PricingHandler) ListLotsTable(c *gin.Context) {
|
||||
Partnumbers: pnMap[r.LotName],
|
||||
Popularity: pop,
|
||||
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 {
|
||||
result[i].Partnumbers = []string{}
|
||||
|
||||
@@ -164,20 +164,28 @@ func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
|
||||
|
||||
// PricelistItemToLocal converts models.PricelistItem to LocalPricelistItem
|
||||
func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *LocalPricelistItem {
|
||||
partnumbers := make(LocalStringList, 0, len(item.Partnumbers))
|
||||
partnumbers = append(partnumbers, item.Partnumbers...)
|
||||
return &LocalPricelistItem{
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
AvailableQty: item.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
}
|
||||
}
|
||||
|
||||
// LocalToPricelistItem converts LocalPricelistItem to 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{
|
||||
ID: local.ID,
|
||||
PricelistID: serverPricelistID,
|
||||
LotName: local.LotName,
|
||||
Price: local.Price,
|
||||
ID: local.ID,
|
||||
PricelistID: serverPricelistID,
|
||||
LotName: local.LotName,
|
||||
Price: local.Price,
|
||||
AvailableQty: local.AvailableQty,
|
||||
Partnumbers: partnumbers,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,30 @@ func (c LocalConfigItems) Total() float64 {
|
||||
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
|
||||
type LocalConfiguration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
@@ -143,10 +167,12 @@ func (LocalPricelist) TableName() string {
|
||||
|
||||
// LocalPricelistItem stores pricelist items
|
||||
type LocalPricelistItem struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
||||
LotName string `gorm:"not null" json:"lot_name"`
|
||||
Price float64 `gorm:"not null" json:"price"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
|
||||
LotName string `gorm:"not null" json:"lot_name"`
|
||||
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 {
|
||||
@@ -167,6 +193,33 @@ func (LocalComponent) TableName() string {
|
||||
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
|
||||
type PendingChange struct {
|
||||
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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -288,60 +288,11 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem)
|
||||
return nil
|
||||
}
|
||||
|
||||
lotSet := make(map[string]struct{}, len(lots))
|
||||
for _, lot := range lots {
|
||||
lotSet[lot] = struct{}{}
|
||||
}
|
||||
|
||||
resolver, err := r.newWarehouseLotResolver()
|
||||
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
|
||||
if err != nil {
|
||||
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 {
|
||||
if qty, ok := qtyByLot[items[i].LotName]; ok {
|
||||
q := qty
|
||||
@@ -352,131 +303,6 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem)
|
||||
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.
|
||||
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||
var item models.PricelistItem
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -132,6 +133,22 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt
|
||||
}
|
||||
|
||||
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 {
|
||||
items = make([]models.PricelistItem, 0, len(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"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
@@ -14,8 +13,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
pricelistsvc "git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -137,7 +138,7 @@ func (s *StockImportService) Import(
|
||||
Total: 100,
|
||||
})
|
||||
|
||||
partnumberMappings, err := s.loadPartnumberMappings()
|
||||
partnumberMatcher, err := lotmatch.NewMappingMatcherFromDB(s.db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -173,7 +174,7 @@ func (s *StockImportService) Import(
|
||||
}
|
||||
partnumber := strings.TrimSpace(row.Article)
|
||||
key := normalizeKey(partnumber)
|
||||
mappedLots := partnumberMappings[key]
|
||||
mappedLots := partnumberMatcher.MatchLots(partnumber)
|
||||
if len(mappedLots) == 0 {
|
||||
unmapped++
|
||||
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) {
|
||||
var logs []models.StockLog
|
||||
if err := s.db.Select("partnumber, price, qty").Where("price > 0").Find(&logs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolver, err := s.newLotResolver()
|
||||
warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
grouped := make(map[string][]weightedPricePoint)
|
||||
for _, l := range logs {
|
||||
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 := make([]pricelistsvc.CreateItemInput, 0, len(warehouseItems))
|
||||
for _, item := range warehouseItems {
|
||||
items = append(items, pricelistsvc.CreateItemInput{
|
||||
LotName: lot,
|
||||
Price: price,
|
||||
PriceMethod: "weighted_median",
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
PriceMethod: item.PriceMethod,
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].LotName < items[j].LotName
|
||||
})
|
||||
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 {
|
||||
if strings.TrimSpace(prev.Partnumber) == "" {
|
||||
return candidate
|
||||
@@ -674,11 +623,9 @@ func normalizeIgnoreMatchType(v string) string {
|
||||
}
|
||||
|
||||
var (
|
||||
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`)
|
||||
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")
|
||||
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`)
|
||||
mxlCellRe = regexp.MustCompile(`\{16,\d+,\s*\{1,1,\s*\{"ru","(.*?)"\}\s*\},0\},(\d+),`)
|
||||
)
|
||||
|
||||
func parseStockRows(filename string, content []byte) ([]stockImportRow, error) {
|
||||
@@ -1020,124 +967,8 @@ func weightedMedian(values []weightedPricePoint) float64 {
|
||||
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 {
|
||||
return strings.ToLower(strings.TrimSpace(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
|
||||
return lotmatch.NormalizeKey(v)
|
||||
}
|
||||
|
||||
func readZipFile(zr *zip.Reader, name string) ([]byte, error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -99,39 +100,42 @@ func TestParseXLSXRows(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLotResolverPrecedenceAndConflicts(t *testing.T) {
|
||||
r := &lotResolver{
|
||||
partnumberToLots: map[string][]string{
|
||||
"pn-1": {"LOT_MAPPED"},
|
||||
"pn-conflict": {"LOT_A", "LOT_B"},
|
||||
resolver := lotmatch.NewLotResolver(
|
||||
[]models.LotPartnumber{
|
||||
{Partnumber: "pn-1", LotName: "LOT_MAPPED"},
|
||||
{Partnumber: "pn-conflict", LotName: "LOT_A"},
|
||||
{Partnumber: "pn-conflict", LotName: "LOT_B"},
|
||||
},
|
||||
exactLots: map[string]string{
|
||||
"cpu_a": "CPU_A",
|
||||
[]models.Lot{
|
||||
{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" {
|
||||
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" {
|
||||
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" {
|
||||
t.Fatalf("prefix mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
||||
}
|
||||
|
||||
_, _, err = r.resolve("abx")
|
||||
if err == nil {
|
||||
t.Fatalf("expected not found error")
|
||||
_, _, err = resolver.Resolve("abx")
|
||||
if err == nil || err != lotmatch.ErrResolveNotFound {
|
||||
t.Fatalf("expected not found error, got %v", err)
|
||||
}
|
||||
|
||||
_, _, err = r.resolve("pn-conflict")
|
||||
if err == nil || err != errResolveConflict {
|
||||
_, _, err = resolver.Resolve("pn-conflict")
|
||||
if err == nil || err != lotmatch.ErrResolveConflict {
|
||||
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 {
|
||||
t.Helper()
|
||||
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
|
||||
func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Info("starting pricelist sync")
|
||||
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.getDB()
|
||||
@@ -592,10 +595,14 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
// Convert and save locally
|
||||
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
|
||||
for i, item := range serverItems {
|
||||
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
|
||||
partnumbers = append(partnumbers, item.Partnumbers...)
|
||||
localItems[i] = localdb.LocalPricelistItem{
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
PricelistID: localPricelistID,
|
||||
LotName: item.LotName,
|
||||
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
|
||||
func (s *Service) PushPendingChanges() (int, error) {
|
||||
if _, err := s.EnsureReadinessForSync(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
removed, err := s.localDB.PurgeOrphanConfigurationPendingChanges()
|
||||
if err != nil {
|
||||
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