From 95b5f8bf656d1a4a24c145dba7c2245927e9bb35 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sat, 7 Feb 2026 06:22:56 +0300 Subject: [PATCH] refactor lot matching into shared module --- internal/handlers/pricelist.go | 412 +++++++++++++----- internal/handlers/pricing.go | 40 +- internal/localdb/converters.go | 22 +- internal/localdb/models.go | 61 ++- internal/lotmatch/resolver.go | 238 ++++++++++ internal/lotmatch/resolver_test.go | 62 +++ internal/repository/pricelist.go | 178 +------- internal/services/pricelist/service.go | 17 + .../pricelist/service_warehouse_test.go | 72 +++ internal/services/stock_import.go | 197 +-------- internal/services/stock_import_test.go | 72 ++- internal/services/sync/service.go | 17 +- internal/warehouse/snapshot.go | 219 ++++++++++ internal/warehouse/snapshot_test.go | 103 +++++ 14 files changed, 1190 insertions(+), 520 deletions(-) create mode 100644 internal/lotmatch/resolver.go create mode 100644 internal/lotmatch/resolver_test.go create mode 100644 internal/services/pricelist/service_warehouse_test.go create mode 100644 internal/warehouse/snapshot.go create mode 100644 internal/warehouse/snapshot_test.go diff --git a/internal/handlers/pricelist.go b/internal/handlers/pricelist.go index 7a70524..48a11f6 100644 --- a/internal/handlers/pricelist.go +++ b/internal/handlers/pricelist.go @@ -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", + }) } diff --git a/internal/handlers/pricing.go b/internal/handlers/pricing.go index 1c90982..c59e203 100644 --- a/internal/handlers/pricing.go +++ b/internal/handlers/pricing.go @@ -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{} diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index daa164f..2beb33d 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -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, } } diff --git a/internal/localdb/models.go b/internal/localdb/models.go index 73b95af..33f1daa 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -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"` diff --git a/internal/lotmatch/resolver.go b/internal/lotmatch/resolver.go new file mode 100644 index 0000000..d669868 --- /dev/null +++ b/internal/lotmatch/resolver.go @@ -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 +} diff --git a/internal/lotmatch/resolver_test.go b/internal/lotmatch/resolver_test.go new file mode 100644 index 0000000..6902a51 --- /dev/null +++ b/internal/lotmatch/resolver_test.go @@ -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) + } +} diff --git a/internal/repository/pricelist.go b/internal/repository/pricelist.go index 0b3b241..d68dcc5 100644 --- a/internal/repository/pricelist.go +++ b/internal/repository/pricelist.go @@ -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 diff --git a/internal/services/pricelist/service.go b/internal/services/pricelist/service.go index 2f22bf6..2f753a0 100644 --- a/internal/services/pricelist/service.go +++ b/internal/services/pricelist/service.go @@ -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 { diff --git a/internal/services/pricelist/service_warehouse_test.go b/internal/services/pricelist/service_warehouse_test.go new file mode 100644 index 0000000..0a65ee9 --- /dev/null +++ b/internal/services/pricelist/service_warehouse_test.go @@ -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) + } +} diff --git a/internal/services/stock_import.go b/internal/services/stock_import.go index e46b9b1..d7a16e6 100644 --- a/internal/services/stock_import.go +++ b/internal/services/stock_import.go @@ -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) { diff --git a/internal/services/stock_import_test.go b/internal/services/stock_import_test.go index 00120e6..613ef41 100644 --- a/internal/services/stock_import_test.go +++ b/internal/services/stock_import_test.go @@ -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{}) diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index b100c54..27af05b 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -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) diff --git a/internal/warehouse/snapshot.go b/internal/warehouse/snapshot.go new file mode 100644 index 0000000..5c4a4c9 --- /dev/null +++ b/internal/warehouse/snapshot.go @@ -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] +} diff --git a/internal/warehouse/snapshot_test.go b/internal/warehouse/snapshot_test.go new file mode 100644 index 0000000..0fca96c --- /dev/null +++ b/internal/warehouse/snapshot_test.go @@ -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 +}