diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index e3a42d9..d113d40 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -720,6 +720,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect pricelists.GET("/latest", pricelistHandler.GetLatest) pricelists.GET("/:id", pricelistHandler.Get) pricelists.GET("/:id/items", pricelistHandler.GetItems) + pricelists.GET("/:id/lots", pricelistHandler.GetLotNames) pricelists.POST("", pricelistHandler.Create) pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress) pricelists.PATCH("/:id/active", pricelistHandler.SetActive) @@ -1327,6 +1328,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect pricingAdmin.POST("/preview", pricingHandler.PreviewPrice) pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll) pricingAdmin.GET("/lots", pricingHandler.ListLots) + pricingAdmin.GET("/lots-table", pricingHandler.ListLotsTable) pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog) pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings) pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping) diff --git a/internal/handlers/pricelist.go b/internal/handlers/pricelist.go index e71355a..7a70524 100644 --- a/internal/handlers/pricelist.go +++ b/internal/handlers/pricelist.go @@ -45,8 +45,15 @@ func (h *PricelistHandler) List(c *gin.Context) { return } - // If offline (empty list), fallback to local pricelists - if total == 0 && h.localDB != nil { + isOffline := false + if v, ok := c.Get("is_offline"); ok { + if b, ok := v.(bool); ok { + isOffline = b + } + } + + // Fallback to local pricelists only in explicit offline mode. + if isOffline && total == 0 && h.localDB != nil { localPLs, err := h.localDB.GetLocalPricelists() if err == nil && len(localPLs) > 0 { if source != "" { @@ -338,6 +345,26 @@ func (h *PricelistHandler) GetItems(c *gin.Context) { }) } +func (h *PricelistHandler) GetLotNames(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) + return + } + + lotNames, err := h.service.GetLotNames(uint(id)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "lot_names": lotNames, + "total": len(lotNames), + }) +} + // CanWrite returns whether the current user can create pricelists func (h *PricelistHandler) CanWrite(c *gin.Context) { canWrite, debugInfo := h.service.CanWriteDebug() diff --git a/internal/handlers/pricing.go b/internal/handlers/pricing.go index f114190..1c90982 100644 --- a/internal/handlers/pricing.go +++ b/internal/handlers/pricing.go @@ -997,6 +997,7 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) { "conflicts": result.Conflicts, "fallback_matches": result.FallbackMatches, "parse_errors": result.ParseErrors, + "qty_parse_errors": result.QtyParseErrors, "ignored": result.Ignored, "mapping_suggestions": result.MappingSuggestions, "import_date": result.ImportDate.Format("2006-01-02"), @@ -1031,6 +1032,7 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) { "conflicts": p.Conflicts, "fallback_matches": p.FallbackMatches, "parse_errors": p.ParseErrors, + "qty_parse_errors": p.QtyParseErrors, "ignored": p.Ignored, "mapping_suggestions": p.MappingSuggestions, "import_date": p.ImportDate, @@ -1183,6 +1185,231 @@ func (h *PricingHandler) DeleteStockIgnoreRule(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"deleted": deleted}) } +type LotTableRow struct { + LotName string `json:"lot_name"` + LotDescription string `json:"lot_description"` + Category string `json:"category"` + Partnumbers []string `json:"partnumbers"` + Popularity float64 `json:"popularity"` + EstimateCount int64 `json:"estimate_count"` + StockQty *float64 `json:"stock_qty"` +} + +func (h *PricingHandler) ListLotsTable(c *gin.Context) { + if h.db == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Список LOT доступен только в онлайн режиме", + "offline": true, + }) + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) + search := strings.TrimSpace(c.Query("search")) + sortFieldParam := c.DefaultQuery("sort", "lot_name") + sortDirParam := strings.ToUpper(c.DefaultQuery("dir", "asc")) + + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 200 { + perPage = 50 + } + if sortDirParam != "ASC" && sortDirParam != "DESC" { + sortDirParam = "ASC" + } + + type lotRow struct { + LotName string `gorm:"column:lot_name"` + LotDescription string `gorm:"column:lot_description"` + CategoryCode *string `gorm:"column:category_code"` + Popularity *float64 `gorm:"column:popularity_score"` + } + + baseQuery := h.db.Table("lot"). + Select("lot.lot_name, lot.lot_description, qt_categories.code as category_code, qt_lot_metadata.popularity_score"). + Joins("LEFT JOIN qt_lot_metadata ON qt_lot_metadata.lot_name = lot.lot_name"). + Joins("LEFT JOIN qt_categories ON qt_categories.id = qt_lot_metadata.category_id") + + if search != "" { + baseQuery = baseQuery.Where("lot.lot_name LIKE ? OR lot.lot_description LIKE ?", "%"+search+"%", "%"+search+"%") + } + + var total int64 + if err := baseQuery.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + allowedDBSorts := map[string]string{ + "lot_name": "lot.lot_name", + "category": "qt_categories.code", + "popularity_score": "qt_lot_metadata.popularity_score", + } + needsComputedSort := sortFieldParam == "estimate_count" || sortFieldParam == "stock_qty" + + var rows []lotRow + rowsQuery := baseQuery.Session(&gorm.Session{}) + if needsComputedSort { + rowsQuery = rowsQuery.Order("lot.lot_name ASC") + } else { + orderCol, ok := allowedDBSorts[sortFieldParam] + if !ok { + orderCol = "lot.lot_name" + } + rowsQuery = rowsQuery.Order(orderCol + " " + sortDirParam).Order("lot.lot_name ASC") + rowsQuery = rowsQuery.Offset((page - 1) * perPage).Limit(perPage) + } + if err := rowsQuery.Find(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if len(rows) == 0 { + c.JSON(http.StatusOK, gin.H{ + "lots": []LotTableRow{}, + "total": total, + "page": page, + "per_page": perPage, + }) + return + } + + // Collect lot names for batch subqueries + lotNames := make([]string, len(rows)) + for i, r := range rows { + lotNames[i] = r.LotName + } + + type countRow struct { + Lot string `gorm:"column:lot"` + Count int64 `gorm:"column:cnt"` + } + var estimateCounts []countRow + if err := h.db.Raw("SELECT lot, COUNT(*) as cnt FROM lot_log WHERE lot IN ? GROUP BY lot", lotNames).Scan(&estimateCounts).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + estimateMap := make(map[string]int64, len(estimateCounts)) + for _, ec := range estimateCounts { + 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 { + 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 { + cat := "" + if r.CategoryCode != nil { + cat = *r.CategoryCode + } + pop := 0.0 + if r.Popularity != nil { + pop = *r.Popularity + } + result[i] = LotTableRow{ + LotName: r.LotName, + LotDescription: r.LotDescription, + Category: cat, + Partnumbers: pnMap[r.LotName], + Popularity: pop, + EstimateCount: estimateMap[r.LotName], + StockQty: stockMap[r.LotName], + } + if result[i].Partnumbers == nil { + result[i].Partnumbers = []string{} + } + } + + if needsComputedSort { + sort.SliceStable(result, func(i, j int) bool { + if sortFieldParam == "estimate_count" { + if result[i].EstimateCount == result[j].EstimateCount { + if sortDirParam == "DESC" { + return result[i].LotName > result[j].LotName + } + return result[i].LotName < result[j].LotName + } + if sortDirParam == "DESC" { + return result[i].EstimateCount > result[j].EstimateCount + } + return result[i].EstimateCount < result[j].EstimateCount + } + qi := 0.0 + if result[i].StockQty != nil { + qi = *result[i].StockQty + } + qj := 0.0 + if result[j].StockQty != nil { + qj = *result[j].StockQty + } + if qi == qj { + if sortDirParam == "DESC" { + return result[i].LotName > result[j].LotName + } + return result[i].LotName < result[j].LotName + } + if sortDirParam == "DESC" { + return qi > qj + } + return qi < qj + }) + + start := (page - 1) * perPage + if start >= len(result) { + result = []LotTableRow{} + } else { + end := start + perPage + if end > len(result) { + end = len(result) + } + result = result[start:end] + } + } + + c.JSON(http.StatusOK, gin.H{ + "lots": result, + "total": total, + "page": page, + "per_page": perPage, + }) +} + func (h *PricingHandler) ListLots(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index f9f3a9a..98dc38c 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "html/template" "log/slog" "net/http" @@ -364,10 +365,27 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) { }) } -// SyncInfoResponse represents sync information +// SyncInfoResponse represents sync information for the modal type SyncInfoResponse struct { - LastSyncAt *time.Time `json:"last_sync_at"` - IsOnline bool `json:"is_online"` + // Connection + DBHost string `json:"db_host"` + DBUser string `json:"db_user"` + DBName string `json:"db_name"` + + // Status + IsOnline bool `json:"is_online"` + LastSyncAt *time.Time `json:"last_sync_at"` + + // Statistics + LotCount int64 `json:"lot_count"` + LotLogCount int64 `json:"lot_log_count"` + ConfigCount int64 `json:"config_count"` + ProjectCount int64 `json:"project_count"` + + // Pending changes + PendingChanges []localdb.PendingChange `json:"pending_changes"` + + // Errors ErrorCount int `json:"error_count"` Errors []SyncError `json:"errors,omitempty"` } @@ -392,31 +410,44 @@ func (h *SyncHandler) GetInfo(c *gin.Context) { // Check online status by pinging MariaDB isOnline := h.checkOnline() + // Get DB connection info + var dbHost, dbUser, dbName string + if settings, err := h.localDB.GetSettings(); err == nil { + dbHost = settings.Host + ":" + fmt.Sprintf("%d", settings.Port) + dbUser = settings.User + dbName = settings.Database + } + // Get sync times lastPricelistSync := h.localDB.GetLastSyncTime() + // Get MariaDB counts (if online) + var lotCount, lotLogCount int64 + if isOnline { + if mariaDB, err := h.connMgr.GetDB(); err == nil { + mariaDB.Table("lot").Count(&lotCount) + mariaDB.Table("lot_log").Count(&lotLogCount) + } + } + + // Get local counts + configCount := h.localDB.CountConfigurations() + projectCount := h.localDB.CountProjects() + // Get error count (only changes with LastError != "") errorCount := int(h.localDB.CountErroredChanges()) - // Get recent errors (last 10) + // Get pending changes changes, err := h.localDB.GetPendingChanges() if err != nil { slog.Error("failed to get pending changes for sync info", "error", err) - // Even if we can't get changes, we can still return the error count - c.JSON(http.StatusOK, SyncInfoResponse{ - LastSyncAt: lastPricelistSync, - IsOnline: isOnline, - ErrorCount: errorCount, - Errors: []SyncError{}, // Return empty errors list - }) - return + changes = []localdb.PendingChange{} } - var errors []SyncError + var syncErrors []SyncError for _, change := range changes { - // Check if there's a last error and it's not empty if change.LastError != "" { - errors = append(errors, SyncError{ + syncErrors = append(syncErrors, SyncError{ Timestamp: change.CreatedAt, Message: change.LastError, }) @@ -424,15 +455,23 @@ func (h *SyncHandler) GetInfo(c *gin.Context) { } // Limit to last 10 errors - if len(errors) > 10 { - errors = errors[:10] + if len(syncErrors) > 10 { + syncErrors = syncErrors[:10] } c.JSON(http.StatusOK, SyncInfoResponse{ - LastSyncAt: lastPricelistSync, - IsOnline: isOnline, - ErrorCount: errorCount, - Errors: errors, + DBHost: dbHost, + DBUser: dbUser, + DBName: dbName, + IsOnline: isOnline, + LastSyncAt: lastPricelistSync, + LotCount: lotCount, + LotLogCount: lotLogCount, + ConfigCount: configCount, + ProjectCount: projectCount, + PendingChanges: changes, + ErrorCount: errorCount, + Errors: syncErrors, }) } diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index 61df6a9..daa164f 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -29,6 +29,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration { IsTemplate: cfg.IsTemplate, ServerCount: cfg.ServerCount, PricelistID: cfg.PricelistID, + OnlyInStock: cfg.OnlyInStock, PriceUpdatedAt: cfg.PriceUpdatedAt, CreatedAt: cfg.CreatedAt, UpdatedAt: time.Now(), @@ -72,6 +73,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration { IsTemplate: local.IsTemplate, ServerCount: local.ServerCount, PricelistID: local.PricelistID, + OnlyInStock: local.OnlyInStock, PriceUpdatedAt: local.PriceUpdatedAt, CreatedAt: local.CreatedAt, } diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 72ad204..85c674e 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -483,6 +483,13 @@ func (l *LocalDB) CountConfigurations() int64 { return count } +// CountProjects returns the number of local projects +func (l *LocalDB) CountProjects() int64 { + var count int64 + l.db.Model(&LocalProject{}).Count(&count) + return count +} + // Pricelist methods // GetLastSyncTime returns the last sync timestamp diff --git a/internal/localdb/models.go b/internal/localdb/models.go index afda562..73b95af 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -73,6 +73,7 @@ type LocalConfiguration struct { IsTemplate bool `gorm:"default:false" json:"is_template"` ServerCount int `gorm:"default:1" json:"server_count"` PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"` + OnlyInStock bool `gorm:"default:false" json:"only_in_stock"` PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/localdb/snapshots.go b/internal/localdb/snapshots.go index 7646085..25ad81b 100644 --- a/internal/localdb/snapshots.go +++ b/internal/localdb/snapshots.go @@ -23,6 +23,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) { "is_template": localCfg.IsTemplate, "server_count": localCfg.ServerCount, "pricelist_id": localCfg.PricelistID, + "only_in_stock": localCfg.OnlyInStock, "price_updated_at": localCfg.PriceUpdatedAt, "created_at": localCfg.CreatedAt, "updated_at": localCfg.UpdatedAt, @@ -52,6 +53,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) { IsTemplate bool `json:"is_template"` ServerCount int `json:"server_count"` PricelistID *uint `json:"pricelist_id"` + OnlyInStock bool `json:"only_in_stock"` PriceUpdatedAt *time.Time `json:"price_updated_at"` OriginalUserID uint `json:"original_user_id"` OriginalUsername string `json:"original_username"` @@ -77,6 +79,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) { IsTemplate: snapshot.IsTemplate, ServerCount: snapshot.ServerCount, PricelistID: snapshot.PricelistID, + OnlyInStock: snapshot.OnlyInStock, PriceUpdatedAt: snapshot.PriceUpdatedAt, OriginalUserID: snapshot.OriginalUserID, OriginalUsername: snapshot.OriginalUsername, diff --git a/internal/models/configuration.go b/internal/models/configuration.go index cb07b92..1c610da 100644 --- a/internal/models/configuration.go +++ b/internal/models/configuration.go @@ -40,25 +40,26 @@ func (c ConfigItems) Total() float64 { } type Configuration struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` - UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership - OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"` - ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"` - AppVersion string `gorm:"size:64" json:"app_version,omitempty"` - Name string `gorm:"size:200;not null" json:"name"` - Items ConfigItems `gorm:"type:json;not null" json:"items"` - TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"` - CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"` - Notes string `gorm:"type:text" json:"notes"` - IsTemplate bool `gorm:"default:false" json:"is_template"` - ServerCount int `gorm:"default:1" json:"server_count"` - PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"` - WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"` - CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"` - DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"` - PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` + UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership + OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"` + ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"` + AppVersion string `gorm:"size:64" json:"app_version,omitempty"` + Name string `gorm:"size:200;not null" json:"name"` + Items ConfigItems `gorm:"type:json;not null" json:"items"` + TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"` + CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"` + Notes string `gorm:"type:text" json:"notes"` + IsTemplate bool `gorm:"default:false" json:"is_template"` + ServerCount int `gorm:"default:1" json:"server_count"` + PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"` + WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"` + CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"` + DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"` + OnlyInStock bool `gorm:"default:false" json:"only_in_stock"` + PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` } diff --git a/internal/models/lot.go b/internal/models/lot.go index de27cdc..a892ad6 100644 --- a/internal/models/lot.go +++ b/internal/models/lot.go @@ -41,7 +41,7 @@ func (Supplier) TableName() string { // StockLog stores warehouse stock snapshots imported from external files. type StockLog struct { StockLogID uint `gorm:"column:stock_log_id;primaryKey;autoIncrement"` - Lot string `gorm:"column:lot;size:255;not null"` + Partnumber string `gorm:"column:partnumber;size:255;not null"` Supplier *string `gorm:"column:supplier;size:255"` Date time.Time `gorm:"column:date;type:date;not null"` Price float64 `gorm:"column:price;not null"` diff --git a/internal/repository/pricelist.go b/internal/repository/pricelist.go index 05b74e4..0b3b241 100644 --- a/internal/repository/pricelist.go +++ b/internal/repository/pricelist.go @@ -3,6 +3,7 @@ package repository import ( "errors" "fmt" + "sort" "strconv" "strings" "time" @@ -26,7 +27,8 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary // ListBySource returns pricelists filtered by source when provided. func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) { - query := r.db.Model(&models.Pricelist{}) + query := r.db.Model(&models.Pricelist{}). + Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)") if source != "" { query = query.Where("source = ?", source) } @@ -51,7 +53,9 @@ func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistS // ListActiveBySource returns active pricelists filtered by source when provided. func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) { - query := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true) + query := r.db.Model(&models.Pricelist{}). + Where("is_active = ?", true). + Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)") if source != "" { query = query.Where("source = ?", source) } @@ -250,6 +254,19 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear return items, total, nil } +// GetLotNames returns distinct lot names from pricelist items. +func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) { + var lotNames []string + if err := r.db.Model(&models.PricelistItem{}). + Where("pricelist_id = ?", pricelistID). + Distinct("lot_name"). + Order("lot_name ASC"). + Pluck("lot_name", &lotNames).Error; err != nil { + return nil, fmt.Errorf("listing pricelist lot names: %w", err) + } + return lotNames, nil +} + func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error { if len(items) == 0 { return nil @@ -271,21 +288,36 @@ func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) return nil } - type lotQty struct { - Lot string - Qty float64 + lotSet := make(map[string]struct{}, len(lots)) + for _, lot := range lots { + lotSet[lot] = struct{}{} } - var qtyRows []lotQty - if err := r.db.Model(&models.StockLog{}). - Select("lot, COALESCE(SUM(qty), 0) AS qty"). - Where("lot IN ?", lots). - Group("lot"). - Scan(&qtyRows).Error; err != nil { + + resolver, err := r.newWarehouseLotResolver() + if err != nil { return err } - qtyByLot := make(map[string]float64, len(qtyRows)) - for _, row := range qtyRows { - qtyByLot[row.Lot] = row.Qty + + 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 @@ -320,6 +352,131 @@ 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 @@ -329,6 +486,28 @@ func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) ( return item.Price, nil } +// GetPricesForLots returns price map for given lots within a pricelist. +func (r *PricelistRepository) GetPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) { + result := make(map[string]float64, len(lotNames)) + if pricelistID == 0 || len(lotNames) == 0 { + return result, nil + } + + var rows []models.PricelistItem + if err := r.db.Select("lot_name, price"). + Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames). + Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if row.Price > 0 { + result[row.LotName] = row.Price + } + } + return result, nil +} + // SetActive toggles active flag on a pricelist. func (r *PricelistRepository) SetActive(id uint, isActive bool) error { return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error diff --git a/internal/repository/pricelist_test.go b/internal/repository/pricelist_test.go index ca698b7..1d10ee8 100644 --- a/internal/repository/pricelist_test.go +++ b/internal/repository/pricelist_test.go @@ -75,6 +75,57 @@ func TestGenerateVersion_IsolatedBySource(t *testing.T) { } } +func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) { + repo := newTestPricelistRepository(t) + db := repo.db + + warehouse := models.Pricelist{ + Source: string(models.PricelistSourceWarehouse), + Version: "S-2026-02-07-001", + CreatedBy: "test", + IsActive: true, + } + if err := db.Create(&warehouse).Error; err != nil { + t.Fatalf("create pricelist: %v", err) + } + if err := db.Create(&models.PricelistItem{ + PricelistID: warehouse.ID, + LotName: "SSD_NVME_03.2T", + Price: 100, + }).Error; err != nil { + t.Fatalf("create pricelist item: %v", err) + } + if err := db.Create(&models.Lot{LotName: "SSD_NVME_03.2T"}).Error; err != nil { + t.Fatalf("create lot: %v", err) + } + qty := 5.0 + if err := db.Create(&models.StockLog{ + Partnumber: "SSD_NVME_03.2T_GEN3_P4610", + Date: time.Now(), + Price: 200, + Qty: &qty, + }).Error; err != nil { + t.Fatalf("create stock log: %v", err) + } + + items, total, err := repo.GetItems(warehouse.ID, 0, 20, "") + if err != nil { + t.Fatalf("GetItems: %v", err) + } + if total != 1 { + t.Fatalf("expected total=1, got %d", total) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].AvailableQty == nil { + t.Fatalf("expected available qty to be set") + } + if *items[0].AvailableQty != 5 { + t.Fatalf("expected available qty=5, got %v", *items[0].AvailableQty) + } +} + func newTestPricelistRepository(t *testing.T) *PricelistRepository { t.Helper() @@ -82,7 +133,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := db.AutoMigrate(&models.Pricelist{}); err != nil { + if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil { t.Fatalf("migrate: %v", err) } return NewPricelistRepository(db) diff --git a/internal/services/configuration.go b/internal/services/configuration.go index 9f9bd1d..94cdf13 100644 --- a/internal/services/configuration.go +++ b/internal/services/configuration.go @@ -53,6 +53,7 @@ type CreateConfigRequest struct { IsTemplate bool `json:"is_template"` ServerCount int `json:"server_count"` PricelistID *uint `json:"pricelist_id,omitempty"` + OnlyInStock bool `json:"only_in_stock"` } func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) { @@ -84,6 +85,7 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq IsTemplate: req.IsTemplate, ServerCount: req.ServerCount, PricelistID: pricelistID, + OnlyInStock: req.OnlyInStock, } if err := s.configRepo.Create(config); err != nil { @@ -145,6 +147,7 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr config.IsTemplate = req.IsTemplate config.ServerCount = req.ServerCount config.PricelistID = pricelistID + config.OnlyInStock = req.OnlyInStock if err := s.configRepo.Update(config); err != nil { return nil, err @@ -222,6 +225,7 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s IsTemplate: false, // Clone is never a template ServerCount: original.ServerCount, PricelistID: original.PricelistID, + OnlyInStock: original.OnlyInStock, } if err := s.configRepo.Create(clone); err != nil { @@ -295,6 +299,7 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques config.IsTemplate = req.IsTemplate config.ServerCount = req.ServerCount config.PricelistID = pricelistID + config.OnlyInStock = req.OnlyInStock if err := s.configRepo.Update(config); err != nil { return nil, err @@ -362,6 +367,7 @@ func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName s IsTemplate: false, ServerCount: original.ServerCount, PricelistID: original.PricelistID, + OnlyInStock: original.OnlyInStock, } if err := s.configRepo.Create(clone); err != nil { diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index 66e097b..d539b2d 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -81,6 +81,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf IsTemplate: req.IsTemplate, ServerCount: req.ServerCount, PricelistID: pricelistID, + OnlyInStock: req.OnlyInStock, CreatedAt: time.Now(), } @@ -163,6 +164,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re localCfg.IsTemplate = req.IsTemplate localCfg.ServerCount = req.ServerCount localCfg.PricelistID = pricelistID + localCfg.OnlyInStock = req.OnlyInStock localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" @@ -268,6 +270,7 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern IsTemplate: false, ServerCount: original.ServerCount, PricelistID: original.PricelistID, + OnlyInStock: original.OnlyInStock, CreatedAt: time.Now(), } @@ -454,6 +457,7 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR localCfg.IsTemplate = req.IsTemplate localCfg.ServerCount = req.ServerCount localCfg.PricelistID = pricelistID + localCfg.OnlyInStock = req.OnlyInStock localCfg.UpdatedAt = time.Now() localCfg.SyncStatus = "pending" @@ -546,6 +550,7 @@ func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newN IsTemplate: false, ServerCount: original.ServerCount, PricelistID: original.PricelistID, + OnlyInStock: original.OnlyInStock, CreatedAt: time.Now(), } @@ -1029,6 +1034,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string, current.IsTemplate = rollbackData.IsTemplate current.ServerCount = rollbackData.ServerCount current.PricelistID = rollbackData.PricelistID + current.OnlyInStock = rollbackData.OnlyInStock current.PriceUpdatedAt = rollbackData.PriceUpdatedAt current.UpdatedAt = time.Now() current.SyncStatus = "pending" diff --git a/internal/services/pricelist/service.go b/internal/services/pricelist/service.go index 165a12d..2f22bf6 100644 --- a/internal/services/pricelist/service.go +++ b/internal/services/pricelist/service.go @@ -31,8 +31,9 @@ type CreateProgress struct { } type CreateItemInput struct { - LotName string - Price float64 + LotName string + Price float64 + PriceMethod string } func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service { @@ -141,6 +142,7 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt PricelistID: pricelist.ID, LotName: strings.TrimSpace(srcItem.LotName), Price: srcItem.Price, + PriceMethod: strings.TrimSpace(srcItem.PriceMethod), }) } } else { @@ -169,6 +171,11 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt } } + if len(items) == 0 { + _ = s.repo.Delete(pricelist.ID) + return nil, fmt.Errorf("cannot create empty pricelist for source %q", source) + } + if err := s.repo.CreateItems(items); err != nil { // Clean up the pricelist if items creation fails s.repo.Delete(pricelist.ID) @@ -262,6 +269,13 @@ func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ( return s.repo.GetItems(pricelistID, offset, perPage, search) } +func (s *Service) GetLotNames(pricelistID uint) ([]string, error) { + if s.repo == nil { + return []string{}, nil + } + return s.repo.GetLotNames(pricelistID) +} + // Delete deletes a pricelist by ID func (s *Service) Delete(id uint) error { if s.repo == nil { diff --git a/internal/services/quote.go b/internal/services/quote.go index e527cf9..ea2520f 100644 --- a/internal/services/quote.go +++ b/internal/services/quote.go @@ -2,6 +2,9 @@ package services import ( "errors" + "fmt" + "sync" + "time" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" @@ -21,6 +24,9 @@ type QuoteService struct { pricelistRepo *repository.PricelistRepository localDB *localdb.LocalDB pricingService *pricing.Service + cacheMu sync.RWMutex + priceCache map[string]cachedLotPrice + cacheTTL time.Duration } func NewQuoteService( @@ -36,9 +42,16 @@ func NewQuoteService( pricelistRepo: pricelistRepo, localDB: localDB, pricingService: pricingService, + priceCache: make(map[string]cachedLotPrice, 4096), + cacheTTL: 10 * time.Second, } } +type cachedLotPrice struct { + price *float64 + expiresAt time.Time +} + type QuoteItem struct { LotName string `json:"lot_name"` Quantity int `json:"quantity"` @@ -70,6 +83,7 @@ type PriceLevelsRequest struct { Quantity int `json:"quantity"` } `json:"items"` PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"` + NoCache bool `json:"no_cache,omitempty"` } type PriceLevelsItem struct { @@ -170,11 +184,55 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve return nil, ErrEmptyQuote } + lotNames := make([]string, 0, len(req.Items)) + seenLots := make(map[string]struct{}, len(req.Items)) + for _, reqItem := range req.Items { + if _, ok := seenLots[reqItem.LotName]; ok { + continue + } + seenLots[reqItem.LotName] = struct{}{} + lotNames = append(lotNames, reqItem.LotName) + } + result := &PriceLevelsResult{ Items: make([]PriceLevelsItem, 0, len(req.Items)), ResolvedPricelistIDs: map[string]uint{}, } + type levelState struct { + id uint + prices map[string]float64 + } + levelBySource := map[models.PricelistSource]*levelState{ + models.PricelistSourceEstimate: {prices: map[string]float64{}}, + models.PricelistSourceWarehouse: {prices: map[string]float64{}}, + models.PricelistSourceCompetitor: {prices: map[string]float64{}}, + } + + for source, st := range levelBySource { + sourceKey := string(source) + if req.PricelistIDs != nil { + if explicitID, ok := req.PricelistIDs[sourceKey]; ok && explicitID > 0 { + st.id = explicitID + result.ResolvedPricelistIDs[sourceKey] = explicitID + } + } + if st.id == 0 && s.pricelistRepo != nil { + latest, err := s.pricelistRepo.GetLatestActiveBySource(sourceKey) + if err == nil { + st.id = latest.ID + result.ResolvedPricelistIDs[sourceKey] = latest.ID + } + } + if st.id == 0 { + continue + } + prices, err := s.lookupPricesByPricelistID(st.id, lotNames, req.NoCache) + if err == nil { + st.prices = prices + } + } + for _, reqItem := range req.Items { item := PriceLevelsItem{ LotName: reqItem.LotName, @@ -182,22 +240,17 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve PriceMissing: make([]string, 0, 3), } - estimatePrice, estimateID := s.lookupLevelPrice(models.PricelistSourceEstimate, reqItem.LotName, req.PricelistIDs) - warehousePrice, warehouseID := s.lookupLevelPrice(models.PricelistSourceWarehouse, reqItem.LotName, req.PricelistIDs) - competitorPrice, competitorID := s.lookupLevelPrice(models.PricelistSourceCompetitor, reqItem.LotName, req.PricelistIDs) - - item.EstimatePrice = estimatePrice - item.WarehousePrice = warehousePrice - item.CompetitorPrice = competitorPrice - - if estimateID != 0 { - result.ResolvedPricelistIDs[string(models.PricelistSourceEstimate)] = estimateID + if p, ok := levelBySource[models.PricelistSourceEstimate].prices[reqItem.LotName]; ok && p > 0 { + price := p + item.EstimatePrice = &price } - if warehouseID != 0 { - result.ResolvedPricelistIDs[string(models.PricelistSourceWarehouse)] = warehouseID + if p, ok := levelBySource[models.PricelistSourceWarehouse].prices[reqItem.LotName]; ok && p > 0 { + price := p + item.WarehousePrice = &price } - if competitorID != 0 { - result.ResolvedPricelistIDs[string(models.PricelistSourceCompetitor)] = competitorID + if p, ok := levelBySource[models.PricelistSourceCompetitor].prices[reqItem.LotName]; ok && p > 0 { + price := p + item.CompetitorPrice = &price } if item.EstimatePrice == nil { @@ -220,6 +273,93 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve return result, nil } +func (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []string, noCache bool) (map[string]float64, error) { + result := make(map[string]float64, len(lotNames)) + if pricelistID == 0 || len(lotNames) == 0 { + return result, nil + } + + missing := make([]string, 0, len(lotNames)) + if noCache { + missing = append(missing, lotNames...) + } else { + now := time.Now() + s.cacheMu.RLock() + for _, lotName := range lotNames { + if entry, ok := s.priceCache[s.cacheKey(pricelistID, lotName)]; ok && entry.expiresAt.After(now) { + if entry.price != nil && *entry.price > 0 { + result[lotName] = *entry.price + } + continue + } + missing = append(missing, lotName) + } + s.cacheMu.RUnlock() + } + + if len(missing) == 0 { + return result, nil + } + + loaded := make(map[string]float64, len(missing)) + if s.pricelistRepo != nil { + prices, err := s.pricelistRepo.GetPricesForLots(pricelistID, missing) + if err == nil { + for lotName, price := range prices { + if price > 0 { + result[lotName] = price + loaded[lotName] = price + } + } + s.updateCache(pricelistID, missing, loaded) + return result, nil + } + } + + // Fallback path (usually offline): local per-lot lookup. + if s.localDB != nil { + for _, lotName := range missing { + price, found := s.lookupPriceByPricelistID(pricelistID, lotName) + if found && price > 0 { + result[lotName] = price + loaded[lotName] = price + } + } + s.updateCache(pricelistID, missing, loaded) + return result, nil + } + + return result, fmt.Errorf("price lookup unavailable for pricelist %d", pricelistID) +} + +func (s *QuoteService) updateCache(pricelistID uint, requested []string, loaded map[string]float64) { + if len(requested) == 0 { + return + } + expiresAt := time.Now().Add(s.cacheTTL) + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + + for _, lotName := range requested { + if price, ok := loaded[lotName]; ok && price > 0 { + priceCopy := price + s.priceCache[s.cacheKey(pricelistID, lotName)] = cachedLotPrice{ + price: &priceCopy, + expiresAt: expiresAt, + } + continue + } + s.priceCache[s.cacheKey(pricelistID, lotName)] = cachedLotPrice{ + price: nil, + expiresAt: expiresAt, + } + } +} + +func (s *QuoteService) cacheKey(pricelistID uint, lotName string) string { + return fmt.Sprintf("%d|%s", pricelistID, lotName) +} + func calculateDelta(target, base *float64) (*float64, *float64) { if target == nil || base == nil { return nil, nil diff --git a/internal/services/stock_import.go b/internal/services/stock_import.go index 94774be..e46b9b1 100644 --- a/internal/services/stock_import.go +++ b/internal/services/stock_import.go @@ -33,6 +33,7 @@ type StockImportProgress struct { Conflicts int `json:"conflicts,omitempty"` FallbackMatches int `json:"fallback_matches,omitempty"` ParseErrors int `json:"parse_errors,omitempty"` + QtyParseErrors int `json:"qty_parse_errors,omitempty"` Ignored int `json:"ignored,omitempty"` MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"` ImportDate string `json:"import_date,omitempty"` @@ -49,6 +50,7 @@ type StockImportResult struct { Conflicts int FallbackMatches int ParseErrors int + QtyParseErrors int Ignored int MappingSuggestions []StockMappingSuggestion ImportDate time.Time @@ -87,6 +89,13 @@ type stockImportRow struct { Vendor string Price float64 Qty float64 + QtyRaw string + QtyInvalid bool +} + +type weightedPricePoint struct { + price float64 + weight float64 } func (s *StockImportService) Import( @@ -128,7 +137,7 @@ func (s *StockImportService) Import( Total: 100, }) - resolver, err := s.newLotResolver() + partnumberMappings, err := s.loadPartnumberMappings() if err != nil { return nil, err } @@ -139,6 +148,7 @@ func (s *StockImportService) Import( conflicts int fallbackMatches int parseErrors int + qtyParseErrors int ignored int suggestionsByPN = make(map[string]StockMappingSuggestion) ) @@ -152,41 +162,32 @@ func (s *StockImportService) Import( parseErrors++ continue } + if row.QtyInvalid { + qtyParseErrors++ + parseErrors++ + continue + } if shouldIgnoreStockRow(row, ignoreRules) { ignored++ continue } - lot, matchType, resolveErr := resolver.resolve(row.Article) - if resolveErr != nil { - trimmedPN := strings.TrimSpace(row.Article) - if trimmedPN != "" { - key := normalizeKey(trimmedPN) - if key != "" { - reason := "unmapped" - if errors.Is(resolveErr, errResolveConflict) { - reason = "conflict" - } - candidate := StockMappingSuggestion{ - Partnumber: trimmedPN, - Description: strings.TrimSpace(row.Description), - Reason: reason, - } - if prev, ok := suggestionsByPN[key]; !ok || - (strings.TrimSpace(prev.Description) == "" && candidate.Description != "") || - (prev.Reason != "conflict" && candidate.Reason == "conflict") { - suggestionsByPN[key] = candidate - } - } - } - if errors.Is(resolveErr, errResolveConflict) { - conflicts++ - } else { - unmapped++ - } - continue - } - if matchType == "article_exact" || matchType == "prefix" { - fallbackMatches++ + partnumber := strings.TrimSpace(row.Article) + key := normalizeKey(partnumber) + mappedLots := partnumberMappings[key] + if len(mappedLots) == 0 { + unmapped++ + suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{ + Partnumber: partnumber, + Description: strings.TrimSpace(row.Description), + Reason: "unmapped", + }) + } else if len(mappedLots) > 1 { + conflicts++ + suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{ + Partnumber: partnumber, + Description: strings.TrimSpace(row.Description), + Reason: "conflict", + }) } var comments *string @@ -199,30 +200,31 @@ func (s *StockImportService) Import( } qty := row.Qty records = append(records, models.StockLog{ - Lot: lot, - Date: importDate, - Price: row.Price, - Comments: comments, - Vendor: vendor, - Qty: &qty, + Partnumber: partnumber, + Date: importDate, + Price: row.Price, + Comments: comments, + Vendor: vendor, + Qty: &qty, }) } suggestions := collectSortedSuggestions(suggestionsByPN, 200) if len(records) == 0 { - return nil, fmt.Errorf("no valid rows after mapping") + return nil, fmt.Errorf("no valid rows after filtering") } report(StockImportProgress{ Status: "mapping", - Message: "Сопоставление article -> lot завершено", + Message: "Валидация строк завершена", RowsTotal: len(rows), ValidRows: len(records), Unmapped: unmapped, Conflicts: conflicts, FallbackMatches: fallbackMatches, ParseErrors: parseErrors, + QtyParseErrors: qtyParseErrors, Current: 40, Total: 100, }) @@ -261,10 +263,14 @@ func (s *StockImportService) Import( return nil, fmt.Errorf("pricelist service unavailable") } pl, err := s.pricelistSvc.CreateForSourceWithProgress(createdBy, string(models.PricelistSourceWarehouse), items, func(p pricelistsvc.CreateProgress) { + current := 70 + int(float64(p.Current)*0.3) + if p.Status != "completed" && current >= 100 { + current = 99 + } report(StockImportProgress{ Status: "recalculating_warehouse", Message: p.Message, - Current: 70 + int(float64(p.Current)*0.3), + Current: current, Total: 100, }) }) @@ -283,6 +289,7 @@ func (s *StockImportService) Import( Conflicts: conflicts, FallbackMatches: fallbackMatches, ParseErrors: parseErrors, + QtyParseErrors: qtyParseErrors, Ignored: ignored, MappingSuggestions: suggestions, ImportDate: importDate, @@ -301,6 +308,7 @@ func (s *StockImportService) Import( Conflicts: result.Conflicts, FallbackMatches: result.FallbackMatches, ParseErrors: result.ParseErrors, + QtyParseErrors: result.QtyParseErrors, Ignored: result.Ignored, MappingSuggestions: result.MappingSuggestions, ImportDate: result.ImportDate.Format("2006-01-02"), @@ -335,28 +343,45 @@ func (s *StockImportService) replaceStockLogs(records []models.StockLog) (int64, func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) { var logs []models.StockLog - if err := s.db.Select("lot, price").Where("price > 0").Find(&logs).Error; err != nil { + if err := s.db.Select("partnumber, price, qty").Where("price > 0").Find(&logs).Error; err != nil { return nil, err } - grouped := make(map[string][]float64) + resolver, err := s.newLotResolver() + if err != nil { + return nil, err + } + + grouped := make(map[string][]weightedPricePoint) for _, l := range logs { - lot := strings.TrimSpace(l.Lot) - if lot == "" || l.Price <= 0 { + partnumber := strings.TrimSpace(l.Partnumber) + if partnumber == "" || l.Price <= 0 { continue } - grouped[lot] = append(grouped[lot], l.Price) + 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, prices := range grouped { - price := median(prices) + for lot, values := range grouped { + price := weightedMedian(values) if price <= 0 { continue } items = append(items, pricelistsvc.CreateItemInput{ - LotName: lot, - Price: price, + LotName: lot, + Price: price, + PriceMethod: "weighted_median", }) } sort.Slice(items, func(i, j int) bool { @@ -365,6 +390,39 @@ func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.Crea 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 + } + if strings.TrimSpace(prev.Description) == "" && strings.TrimSpace(candidate.Description) != "" { + prev.Description = candidate.Description + } + if prev.Reason != "conflict" && candidate.Reason == "conflict" { + prev.Reason = "conflict" + } + return prev +} + func (s *StockImportService) ListMappings(page, perPage int, search string) ([]models.LotPartnumber, int64, error) { if s.db == nil { return nil, 0, fmt.Errorf("offline mode: mappings unavailable") @@ -669,7 +727,8 @@ func parseMXLRows(content []byte) ([]stockImportRow, error) { if err != nil { continue } - qty, err := parseLocalizedFloat(r[6]) + qtyRaw := strings.TrimSpace(r[6]) + qty, err := parseLocalizedQty(qtyRaw) if err != nil { qty = 0 } @@ -680,6 +739,8 @@ func parseMXLRows(content []byte) ([]stockImportRow, error) { Vendor: strings.TrimSpace(r[4]), Price: price, Qty: qty, + QtyRaw: qtyRaw, + QtyInvalid: err != nil, }) } return result, nil @@ -767,6 +828,9 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) { idxVendor, hasVendor := headers["вендор"] idxPrice := headers["стоимость"] idxQty, hasQty := headers["свободно"] + if !hasQty { + return nil, fmt.Errorf("xlsx parsing failed: qty column 'Свободно' not found") + } for i := headerRow + 1; i < len(grid); i++ { row := grid[i] article := strings.TrimSpace(row[idxArticle]) @@ -778,10 +842,14 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) { continue } qty := 0.0 + qtyRaw := "" + qtyInvalid := false if hasQty { - qty, err = parseLocalizedFloat(row[idxQty]) + qtyRaw = strings.TrimSpace(row[idxQty]) + qty, err = parseLocalizedQty(qtyRaw) if err != nil { qty = 0 + qtyInvalid = true } } @@ -805,6 +873,8 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) { Vendor: vendor, Price: price, Qty: qty, + QtyRaw: qtyRaw, + QtyInvalid: qtyInvalid, }) } return result, nil @@ -821,6 +891,23 @@ func parseLocalizedFloat(value string) (float64, error) { return strconv.ParseFloat(clean, 64) } +func parseLocalizedQty(value string) (float64, error) { + clean := strings.TrimSpace(value) + if clean == "" { + return 0, fmt.Errorf("empty qty") + } + if v, err := parseLocalizedFloat(clean); err == nil { + return v, nil + } + // Tolerate strings like "1 200 шт" by extracting the first numeric token. + re := regexp.MustCompile(`[-+]?\d[\d\s\u00a0]*(?:[.,]\d+)?`) + match := re.FindString(clean) + if strings.TrimSpace(match) == "" { + return 0, fmt.Errorf("invalid qty: %s", value) + } + return parseLocalizedFloat(match) +} + func detectImportDate(content []byte, filename string, fileModTime time.Time) time.Time { if d, ok := extractDateFromText(string(content)); ok { return d @@ -885,6 +972,54 @@ func median(values []float64) float64 { return c[n/2] } +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) + w := v.weight + if w > 0 { + items = append(items, pair{price: v.price, weight: w}) + totalWeight += w + } + } + + // Fallback for rows without positive weights. + 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 +} + type lotResolver struct { partnumberToLots map[string][]string exactLots map[string]string diff --git a/internal/services/stock_import_test.go b/internal/services/stock_import_test.go index 1413096..00120e6 100644 --- a/internal/services/stock_import_test.go +++ b/internal/services/stock_import_test.go @@ -47,6 +47,35 @@ func TestParseMXLRows(t *testing.T) { } } +func TestParseMXLRows_EmptyQtyMarkedInvalid(t *testing.T) { + content := strings.Join([]string{ + `MOXCEL`, + `{16,2,{1,1,{"ru","Папка"}},0},1,`, + `{16,2,{1,1,{"ru","Артикул"}},0},2,`, + `{16,2,{1,1,{"ru","Описание"}},0},3,`, + `{16,2,{1,1,{"ru","Вендор"}},0},4,`, + `{16,2,{1,1,{"ru","Стоимость"}},0},5,`, + `{16,2,{1,1,{"ru","Свободно"}},0},6,`, + `{16,2,{1,1,{"ru","Серверы"}},0},1,`, + `{16,2,{1,1,{"ru","CPU_X"}},0},2,`, + `{16,2,{1,1,{"ru","Процессор"}},0},3,`, + `{16,2,{1,1,{"ru","AMD"}},0},4,`, + `{16,2,{1,1,{"ru","125,50"}},0},5,`, + `{16,2,{1,1,{"ru",""}},0},6,`, + }, "\n") + + rows, err := parseMXLRows([]byte(content)) + if err != nil { + t.Fatalf("parseMXLRows: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if !rows[0].QtyInvalid { + t.Fatalf("expected QtyInvalid=true for empty qty") + } +} + func TestParseXLSXRows(t *testing.T) { xlsx := buildMinimalXLSX(t, []string{ "Папка", "Артикул", "Описание", "Вендор", "Стоимость", "Свободно", @@ -114,9 +143,9 @@ func TestImportNoValidRowsKeepsStockLog(t *testing.T) { } existing := models.StockLog{ - Lot: "CPU_A", - Date: time.Now(), - Price: 10, + Partnumber: "CPU_A", + Date: time.Now(), + Price: 10, } if err := db.Create(&existing).Error; err != nil { t.Fatalf("seed stock_log: %v", err) @@ -152,14 +181,14 @@ func TestReplaceStockLogs(t *testing.T) { t.Fatalf("automigrate stock_log: %v", err) } - if err := db.Create(&models.StockLog{Lot: "OLD", Date: time.Now(), Price: 1}).Error; err != nil { + if err := db.Create(&models.StockLog{Partnumber: "OLD", Date: time.Now(), Price: 1}).Error; err != nil { t.Fatalf("seed old row: %v", err) } svc := NewStockImportService(db, nil) records := []models.StockLog{ - {Lot: "NEW_1", Date: time.Now(), Price: 2}, - {Lot: "NEW_2", Date: time.Now(), Price: 3}, + {Partnumber: "NEW_1", Date: time.Now(), Price: 2}, + {Partnumber: "NEW_2", Date: time.Now(), Price: 3}, } deleted, inserted, err := svc.replaceStockLogs(records) @@ -171,14 +200,73 @@ func TestReplaceStockLogs(t *testing.T) { } var rows []models.StockLog - if err := db.Order("lot").Find(&rows).Error; err != nil { + if err := db.Order("partnumber").Find(&rows).Error; err != nil { t.Fatalf("read rows: %v", err) } - if len(rows) != 2 || rows[0].Lot != "NEW_1" || rows[1].Lot != "NEW_2" { + if len(rows) != 2 || rows[0].Partnumber != "NEW_1" || rows[1].Partnumber != "NEW_2" { t.Fatalf("unexpected rows after replace: %#v", rows) } } +func TestWeightedMedian(t *testing.T) { + got := weightedMedian([]weightedPricePoint{ + {price: 10, weight: 1}, + {price: 20, weight: 3}, + {price: 50, weight: 1}, + }) + if got != 20 { + t.Fatalf("expected weighted median 20, got %v", got) + } +} + +func TestWeightedMedianFallbackToMedianWhenNoWeights(t *testing.T) { + got := weightedMedian([]weightedPricePoint{ + {price: 10, weight: 0}, + {price: 20, weight: 0}, + {price: 30, weight: 0}, + }) + if got != 20 { + t.Fatalf("expected fallback median 20, got %v", got) + } +} + +func TestBuildWarehousePricelistItems_UsesPrefixResolver(t *testing.T) { + db := openTestDB(t) + if err := db.AutoMigrate(&models.StockLog{}, &models.Lot{}, &models.LotPartnumber{}); err != nil { + t.Fatalf("automigrate: %v", err) + } + + if err := db.Create(&models.Lot{LotName: "CPU_A"}).Error; err != nil { + t.Fatalf("seed lot: %v", err) + } + + qty1 := 3.0 + qty2 := 1.0 + now := time.Now() + rows := []models.StockLog{ + {Partnumber: "CPU_A-001", Date: now, Price: 100, Qty: &qty1}, + {Partnumber: "CPU_A-XYZ", Date: now, Price: 120, Qty: &qty2}, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatalf("seed stock_log: %v", err) + } + + svc := NewStockImportService(db, nil) + items, err := svc.buildWarehousePricelistItems() + if err != nil { + t.Fatalf("buildWarehousePricelistItems: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].LotName != "CPU_A" { + t.Fatalf("expected lot CPU_A, got %s", items[0].LotName) + } + if items[0].Price != 100 { + t.Fatalf("expected weighted median 100, got %v", items[0].Price) + } +} + 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_projects_push_test.go b/internal/services/sync/service_projects_push_test.go index bafd093..97a22e5 100644 --- a/internal/services/sync/service_projects_push_test.go +++ b/internal/services/sync/service_projects_push_test.go @@ -349,6 +349,10 @@ CREATE TABLE qt_configurations ( is_template INTEGER NOT NULL DEFAULT 0, server_count INTEGER NOT NULL DEFAULT 1, pricelist_id INTEGER NULL, + warehouse_pricelist_id INTEGER NULL, + competitor_pricelist_id INTEGER NULL, + disable_price_refresh INTEGER NOT NULL DEFAULT 0, + only_in_stock INTEGER NOT NULL DEFAULT 0, price_updated_at DATETIME NULL, created_at DATETIME );`).Error; err != nil { diff --git a/migrations/019_rename_stock_log_lot_to_partnumber.sql b/migrations/019_rename_stock_log_lot_to_partnumber.sql new file mode 100644 index 0000000..c3110b4 --- /dev/null +++ b/migrations/019_rename_stock_log_lot_to_partnumber.sql @@ -0,0 +1,2 @@ +ALTER TABLE stock_log + CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL; diff --git a/migrations/020_add_only_in_stock_to_configurations.sql b/migrations/020_add_only_in_stock_to_configurations.sql new file mode 100644 index 0000000..0f48a8d --- /dev/null +++ b/migrations/020_add_only_in_stock_to_configurations.sql @@ -0,0 +1,3 @@ +-- Add only_in_stock toggle to configuration settings persisted in MariaDB. +ALTER TABLE qt_configurations + ADD COLUMN IF NOT EXISTS only_in_stock BOOLEAN NOT NULL DEFAULT FALSE AFTER disable_price_refresh; diff --git a/migrations/021_add_pricelist_items_pricelist_lot_index.sql b/migrations/021_add_pricelist_items_pricelist_lot_index.sql new file mode 100644 index 0000000..69c6bf7 --- /dev/null +++ b/migrations/021_add_pricelist_items_pricelist_lot_index.sql @@ -0,0 +1,19 @@ +-- Ensure fast lookup for /api/quote/price-levels batched queries: +-- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...) +SET @has_idx := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'qt_pricelist_items' + AND index_name IN ('idx_qt_pricelist_items_pricelist_lot', 'idx_pricelist_lot') +); + +SET @ddl := IF( + @has_idx = 0, + 'ALTER TABLE qt_pricelist_items ADD INDEX idx_qt_pricelist_items_pricelist_lot (pricelist_id, lot_name)', + 'SELECT ''idx_qt_pricelist_items_pricelist_lot already exists, skip''' +); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/web/templates/admin_pricing.html b/web/templates/admin_pricing.html index 8cfc7c1..03963a2 100644 --- a/web/templates/admin_pricing.html +++ b/web/templates/admin_pricing.html @@ -7,7 +7,8 @@
- + + @@ -33,21 +34,22 @@
- + @@ -75,6 +77,7 @@ Версия + Тип прайслиста Дата Автор Позиций @@ -85,7 +88,7 @@ - Загрузка... + Загрузка... @@ -96,6 +99,7 @@