docs: add CSV export pattern documentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 13:14:12 +03:00
parent 9bc01831c9
commit 06aa7c7067

297
csv_export.md Normal file
View File

@@ -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`