# CSV Export Pattern (Go + GORM) ## Архитектура (3-слойная) ### 1. Handler Layer (HTTP) **Задачи**: Обработка HTTP-запроса, установка заголовков, инициация экспорта ```go func (h *PricelistHandler) ExportCSV(c *gin.Context) { // 1. Валидация параметров id, err := strconv.ParseUint(c.Param("id"), 10, 32) // 2. Получение метаданных для формирования имени файла pl, err := h.service.GetByID(uint(id)) // 3. Установка HTTP-заголовков для скачивания filename := fmt.Sprintf("pricelist_%s.csv", pl.Version) c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) // 4. UTF-8 BOM для Excel-совместимости c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) // 5. Настройка CSV writer writer := csv.NewWriter(c.Writer) writer.Comma = ';' // Точка с запятой для Excel defer writer.Flush() // 6. Динамические заголовки (зависят от типа данных) isWarehouse := strings.ToLower(pl.Source) == "warehouse" var header []string if isWarehouse { header = []string{"Артикул", "Категория", "Описание", "Доступно", "Partnumbers", "Цена, $", "Настройки"} } else { header = []string{"Артикул", "Категория", "Описание", "Цена, $", "Настройки"} } writer.Write(header) // 7. Streaming в batches через callback err = h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error { for _, item := range items { row := buildRow(item, isWarehouse) if err := writer.Write(row); err != nil { return err } } writer.Flush() // Flush после каждого batch return nil }) } ``` ### 2. Service Layer **Задачи**: Оркестрация, делегирование в репозиторий ```go func (s *Service) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error { if s.repo == nil { return fmt.Errorf("offline mode: cannot stream pricelist items") } return s.repo.StreamItemsForExport(pricelistID, batchSize, callback) } ``` ### 3. Repository Layer (Критичный) **Задачи**: Batch-загрузка из БД, оптимизация запросов, enrichment ```go func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error { if batchSize <= 0 { batchSize = 500 // Default batch size } // Проверка типа pricelist для conditional enrichment var pl models.Pricelist isWarehouse := false if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil { isWarehouse = pl.Source == string(models.PricelistSourceWarehouse) } offset := 0 for { var items []models.PricelistItem // ⚡ КЛЮЧЕВОЙ МОМЕНТ: JOIN для избежания N+1 запросов err := r.db.Table("qt_pricelist_items AS pi"). Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category"). Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name"). Where("pi.pricelist_id = ?", pricelistID). Order("pi.lot_name"). Offset(offset). Limit(batchSize). Scan(&items).Error if err != nil || len(items) == 0 { break } // Conditional enrichment для warehouse данных if isWarehouse { r.enrichWarehouseItems(items) // Добавление qty, partnumbers } // Вызов callback для обработки batch if err := callback(items); err != nil { return err } if len(items) < batchSize { break // Последний batch } offset += batchSize } return nil } ``` ## Ключевые паттерны ### 1. Streaming (не загружать все в память) ```go // ❌ НЕ ТАК: var allItems []Item db.Find(&allItems) // Может упасть на миллионах записей // ✅ ТАК: for offset := 0; ; offset += batchSize { var batch []Item db.Offset(offset).Limit(batchSize).Find(&batch) processBatch(batch) if len(batch) < batchSize { break } } ``` ### 2. Callback Pattern для гибкости ```go // Service не знает о CSV - может использоваться для любого экспорта func StreamItems(callback func([]Item) error) error ``` ### 3. JOIN для избежания N+1 ```go // ❌ N+1 problem: items := getItems() for _, item := range items { description := getLotDescription(item.LotName) // N запросов } // ✅ JOIN: db.Table("items AS i"). Select("i.*, COALESCE(l.description, '') AS description"). Joins("LEFT JOIN lots AS l ON l.name = i.lot_name") ``` ### 4. UTF-8 BOM для Excel ```go // Excel на Windows требует BOM для корректного отображения UTF-8 c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) ``` ### 5. Точка с запятой для Excel ```go writer := csv.NewWriter(c.Writer) writer.Comma = ';' // Excel в русской локали использует ; ``` ### 6. Graceful Error Handling ```go // После начала streaming нельзя вернуть JSON if err != nil { // Уже начали писать CSV, поэтому пишем текст c.String(http.StatusInternalServerError, "Export failed: %v", err) return } ``` ## Conditional Enrichment Pattern ```go // Для warehouse прайслистов добавляем дополнительные поля func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error { // 1. Собрать уникальные lot_names lots := make([]string, 0, len(items)) seen := make(map[string]struct{}) for _, item := range items { if _, ok := seen[item.LotName]; !ok { lots = append(lots, item.LotName) seen[item.LotName] = struct{}{} } } // 2. Batch-загрузка метрик (qty, partnumbers) qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true) // 3. Обогащение items for i := range items { if qty, ok := qtyByLot[items[i].LotName]; ok { items[i].AvailableQty = &qty } items[i].Partnumbers = partnumbersByLot[items[i].LotName] } return nil } ``` ## Virtual Fields Pattern ```go type PricelistItem struct { // Stored fields ID uint `gorm:"primaryKey"` LotName string `gorm:"size:255"` Price float64 `gorm:"type:decimal(12,2)"` // Virtual fields (populated via JOIN or programmatically) LotDescription string `gorm:"-:migration" json:"lot_description,omitempty"` Category string `gorm:"-:migration" json:"category,omitempty"` AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"` Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"` } ``` - `gorm:"-:migration"` - не создавать колонку в БД, но маппить при SELECT - `gorm:"-"` - полностью игнорировать при БД операциях ## Checklist для CSV Export - [ ] HTTP заголовки: Content-Type, Content-Disposition - [ ] UTF-8 BOM для Excel (0xEF, 0xBB, 0xBF) - [ ] Разделитель (`;` для русской локали Excel) - [ ] Streaming с batch processing (не загружать всё в память) - [ ] JOIN для избежания N+1 запросов - [ ] Flush после каждого batch - [ ] Graceful error handling (нельзя JSON после начала streaming) - [ ] Динамические заголовки (если нужно) - [ ] Conditional enrichment (если данные зависят от типа) ## Когда использовать этот паттерн ✅ **Используй когда:** - Экспорт больших датасетов (>1000 записей) - Нужна Excel-совместимость - Связанные данные из нескольких таблиц - Conditional логика enrichment ❌ **Не нужен когда:** - Малые датасеты (<100 записей) - можно загрузить всё сразу - Экспорт JSON/XML - другие подходы - Нет связанных данных - можно упростить ## Пример роутинга (Gin) ```go // В файле роутера func SetupRoutes(router *gin.Engine, handler *PricelistHandler) { api := router.Group("/api") { pricelists := api.Group("/pricelists") { pricelists.GET("/:id/export", handler.ExportCSV) } } } ``` ## Импорты ```go import ( "encoding/csv" "fmt" "strconv" "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" ) ``` ## Performance Notes 1. **Batch Size**: 500-1000 оптимально для большинства случаев 2. **JOIN vs N+1**: JOIN на порядки быстрее при >100 записях 3. **Memory**: Streaming позволяет экспортировать миллионы записей с минимальной памятью 4. **Indexes**: Убедись что есть индексы на JOIN колонках ## Источник Реализовано в проекте PriceForge: - Handler: `internal/handlers/pricelist.go:245-346` - Service: `internal/services/pricelist/service.go:373-379` - Repository: `internal/repository/pricelist.go:475-533` - Models: `internal/models/pricelist.go`