diff --git a/csv_export.md b/csv_export.md new file mode 100644 index 0000000..c3c52cf --- /dev/null +++ b/csv_export.md @@ -0,0 +1,297 @@ +# 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`