package repository import ( "errors" "fmt" "sort" "strconv" "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/lotmatch" "git.mchus.pro/mchus/quoteforge/internal/models" "gorm.io/gorm" ) type PricelistRepository struct { db *gorm.DB } func NewPricelistRepository(db *gorm.DB) *PricelistRepository { return &PricelistRepository{db: db} } // List returns pricelists with pagination func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) { return r.ListBySource("", offset, limit) } // 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{}). Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)") if source != "" { query = query.Where("source = ?", source) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("counting pricelists: %w", err) } var pricelists []models.Pricelist if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil { return nil, 0, fmt.Errorf("listing pricelists: %w", err) } return r.toSummaries(pricelists), total, nil } // ListActive returns active pricelists with pagination. func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) { return r.ListActiveBySource("", offset, limit) } // 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). Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)") if source != "" { query = query.Where("source = ?", source) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("counting active pricelists: %w", err) } var pricelists []models.Pricelist if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil { return nil, 0, fmt.Errorf("listing active pricelists: %w", err) } return r.toSummaries(pricelists), total, nil } // CountActive returns the number of active pricelists. func (r *PricelistRepository) CountActive() (int64, error) { var total int64 if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil { return 0, fmt.Errorf("counting active pricelists: %w", err) } return total, nil } func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []models.PricelistSummary { // Get item counts for each pricelist summaries := make([]models.PricelistSummary, len(pricelists)) for i, pl := range pricelists { var itemCount int64 r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount) usageCount, _ := r.CountUsage(pl.ID) summaries[i] = models.PricelistSummary{ ID: pl.ID, Source: pl.Source, Version: pl.Version, Notification: pl.Notification, CreatedAt: pl.CreatedAt, CreatedBy: pl.CreatedBy, IsActive: pl.IsActive, UsageCount: int(usageCount), ExpiresAt: pl.ExpiresAt, ItemCount: itemCount, } } return summaries } // GetByID returns a pricelist by ID func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) { var pricelist models.Pricelist if err := r.db.First(&pricelist, id).Error; err != nil { return nil, fmt.Errorf("getting pricelist: %w", err) } // Get item count var itemCount int64 r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount) pricelist.ItemCount = int(itemCount) if usageCount, err := r.CountUsage(id); err == nil { pricelist.UsageCount = int(usageCount) } return &pricelist, nil } // GetByVersion returns a pricelist by version string func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) { return r.GetBySourceAndVersion(string(models.PricelistSourceEstimate), version) } // GetBySourceAndVersion returns a pricelist by source/version. func (r *PricelistRepository) GetBySourceAndVersion(source, version string) (*models.Pricelist, error) { var pricelist models.Pricelist if err := r.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil { return nil, fmt.Errorf("getting pricelist by version: %w", err) } return &pricelist, nil } // GetLatestActive returns the most recent active pricelist func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) { return r.GetLatestActiveBySource(string(models.PricelistSourceEstimate)) } // GetLatestActiveBySource returns the most recent active pricelist by source. func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) { var pricelist models.Pricelist if err := r.db. Where("is_active = ? AND source = ?", true, source). Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)"). Order("created_at DESC, id DESC"). First(&pricelist).Error; err != nil { return nil, fmt.Errorf("getting latest pricelist: %w", err) } return &pricelist, nil } // Create creates a new pricelist func (r *PricelistRepository) Create(pricelist *models.Pricelist) error { if err := r.db.Create(pricelist).Error; err != nil { return fmt.Errorf("creating pricelist: %w", err) } return nil } // Update updates a pricelist func (r *PricelistRepository) Update(pricelist *models.Pricelist) error { if err := r.db.Save(pricelist).Error; err != nil { return fmt.Errorf("updating pricelist: %w", err) } return nil } // Delete deletes a pricelist if usage_count is 0 func (r *PricelistRepository) Delete(id uint) error { usageCount, err := r.CountUsage(id) if err != nil { return err } if usageCount > 0 { return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount) } // Delete items first if err := r.db.Where("pricelist_id = ?", id).Delete(&models.PricelistItem{}).Error; err != nil { return fmt.Errorf("deleting pricelist items: %w", err) } // Delete pricelist if err := r.db.Delete(&models.Pricelist{}, id).Error; err != nil { return fmt.Errorf("deleting pricelist: %w", err) } return nil } // CreateItems batch inserts pricelist items func (r *PricelistRepository) CreateItems(items []models.PricelistItem) error { if len(items) == 0 { return nil } // Use batch insert for better performance batchSize := 500 for i := 0; i < len(items); i += batchSize { end := i + batchSize if end > len(items) { end = len(items) } if err := r.db.CreateInBatches(items[i:end], batchSize).Error; err != nil { return fmt.Errorf("batch inserting pricelist items: %w", err) } } return nil } // GetItems returns pricelist items with pagination func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, search string) ([]models.PricelistItem, int64, error) { var total int64 query := r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pricelistID) if search != "" { query = query.Where("lot_name LIKE ?", "%"+search+"%") } if err := query.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("counting pricelist items: %w", err) } var items []models.PricelistItem if err := query.Order("lot_name").Offset(offset).Limit(limit).Find(&items).Error; err != nil { return nil, 0, fmt.Errorf("listing pricelist items: %w", err) } // Enrich with lot descriptions for i := range items { var lot models.Lot if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil { items[i].LotDescription = lot.LotDescription } items[i].Category = strings.TrimSpace(items[i].LotCategory) } if err := r.enrichItemsWithStock(items); err != nil { return nil, 0, fmt.Errorf("enriching pricelist items with stock: %w", err) } return items, total, nil } func (r *PricelistRepository) enrichItemsWithStock(items []models.PricelistItem) error { if len(items) == 0 { return nil } resolver, err := lotmatch.NewLotResolverFromDB(r.db) if err != nil { return err } type stockRow struct { Partnumber string `gorm:"column:partnumber"` Qty *float64 `gorm:"column:qty"` } rows := make([]stockRow, 0) if err := r.db.Raw(` SELECT s.partnumber, s.qty FROM stock_log s INNER JOIN ( SELECT partnumber, MAX(date) AS max_date FROM stock_log GROUP BY partnumber ) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date WHERE s.qty IS NOT NULL `).Scan(&rows).Error; err != nil { return err } lotTotals := make(map[string]float64, len(items)) lotPartnumbers := make(map[string][]string, len(items)) seenPartnumbers := make(map[string]map[string]struct{}, len(items)) for i := range rows { row := rows[i] if strings.TrimSpace(row.Partnumber) == "" { continue } lotName, _, resolveErr := resolver.Resolve(row.Partnumber) if resolveErr != nil || strings.TrimSpace(lotName) == "" { continue } if row.Qty != nil { lotTotals[lotName] += *row.Qty } pn := strings.TrimSpace(row.Partnumber) if pn == "" { continue } if _, ok := seenPartnumbers[lotName]; !ok { seenPartnumbers[lotName] = make(map[string]struct{}, 4) } key := strings.ToLower(pn) if _, exists := seenPartnumbers[lotName][key]; exists { continue } seenPartnumbers[lotName][key] = struct{}{} lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn) } for i := range items { lotName := items[i].LotName if qty, ok := lotTotals[lotName]; ok { qtyCopy := qty items[i].AvailableQty = &qtyCopy } if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 { sort.Slice(partnumbers, func(a, b int) bool { return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b]) }) items[i].Partnumbers = partnumbers } } return 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 } // GetPriceForLot returns item price for a lot within a pricelist. func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) { var item models.PricelistItem if err := r.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).First(&item).Error; err != nil { return 0, err } 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 } // GenerateVersion generates a new version string in format YYYY-MM-DD-NNN func (r *PricelistRepository) GenerateVersion() (string, error) { return r.GenerateVersionBySource(string(models.PricelistSourceEstimate)) } // GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source. func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) { today := time.Now().Format("2006-01-02") prefix := versionPrefixBySource(source) var last models.Pricelist err := r.db.Model(&models.Pricelist{}). Select("version"). Where("source = ? AND version LIKE ?", source, prefix+"-"+today+"-%"). Order("version DESC"). Limit(1). Take(&last).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Sprintf("%s-%s-001", prefix, today), nil } return "", fmt.Errorf("loading latest today's pricelist version: %w", err) } parts := strings.Split(last.Version, "-") if len(parts) < 4 { return "", fmt.Errorf("invalid pricelist version format: %s", last.Version) } n, err := strconv.Atoi(parts[len(parts)-1]) if err != nil { return "", fmt.Errorf("parsing pricelist sequence %q: %w", parts[len(parts)-1], err) } return fmt.Sprintf("%s-%s-%03d", prefix, today, n+1), nil } func versionPrefixBySource(source string) string { switch models.NormalizePricelistSource(source) { case models.PricelistSourceWarehouse: return "S" case models.PricelistSourceCompetitor: return "B" default: return "E" } } // GetPriceForLotBySource returns item price for a lot from latest active pricelist of source. func (r *PricelistRepository) GetPriceForLotBySource(source, lotName string) (float64, uint, error) { latest, err := r.GetLatestActiveBySource(source) if err != nil { return 0, 0, err } price, err := r.GetPriceForLot(latest.ID, lotName) if err != nil { return 0, 0, err } return price, latest.ID, nil } // CanWrite checks if the current database user has INSERT permission on qt_pricelists func (r *PricelistRepository) CanWrite() bool { canWrite, _ := r.CanWriteDebug() return canWrite } // CanWriteDebug checks write permission and returns debug info // Uses raw SQL with explicit columns to avoid schema mismatch issues func (r *PricelistRepository) CanWriteDebug() (bool, string) { // Check if table exists first var count int64 if err := r.db.Table("qt_pricelists").Count(&count).Error; err != nil { return false, fmt.Sprintf("table check failed: %v", err) } // Use raw SQL with only essential columns that always exist // This avoids GORM model validation and schema mismatch issues tx := r.db.Begin() if tx.Error != nil { return false, fmt.Sprintf("begin tx failed: %v", tx.Error) } defer tx.Rollback() // Always rollback - this is just a permission test testVersion := fmt.Sprintf("test-%06d", time.Now().Unix()%1000000) // Raw SQL insert with only core columns err := tx.Exec(` INSERT INTO qt_pricelists (version, created_by, is_active) VALUES (?, 'system', 1) `, testVersion).Error if err != nil { // Check if it's a permission error vs other errors errStr := err.Error() if strings.Contains(errStr, "INSERT command denied") || strings.Contains(errStr, "Access denied") { return false, "no write permission" } return false, fmt.Sprintf("insert failed: %v", err) } return true, "ok" } // IncrementUsageCount increments the usage count for a pricelist func (r *PricelistRepository) IncrementUsageCount(id uint) error { return r.db.Model(&models.Pricelist{}).Where("id = ?", id). UpdateColumn("usage_count", gorm.Expr("usage_count + 1")).Error } // DecrementUsageCount decrements the usage count for a pricelist func (r *PricelistRepository) DecrementUsageCount(id uint) error { return r.db.Model(&models.Pricelist{}).Where("id = ?", id). UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error } // CountUsage returns number of configurations referencing pricelist. func (r *PricelistRepository) CountUsage(id uint) (int64, error) { var count int64 if err := r.db.Table("qt_configurations").Where("pricelist_id = ?", id).Count(&count).Error; err != nil { return 0, fmt.Errorf("counting configurations for pricelist %d: %w", id, err) } return count, nil } // GetExpiredUnused returns pricelists that are expired and unused func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) { var pricelists []models.Pricelist if err := r.db.Where("expires_at < ? AND usage_count = 0", time.Now()). Find(&pricelists).Error; err != nil { return nil, fmt.Errorf("getting expired pricelists: %w", err) } return pricelists, nil }