Files
PriceForge/bible/patterns.md
2026-02-20 19:01:07 +03:00

5.1 KiB
Raw Blame History

Code Patterns

CSV Export (3-layer architecture)

Used for pricelist export.

Implementation:

  • Handler: internal/handlers/pricelist.goExportCSV
  • Service: internal/services/pricelist/service.goStreamItemsForExport
  • Repository: internal/repository/pricelist.goStreamItemsForExport
  • Models: internal/models/pricelist.go

1. Handler Layer

func (h *PricelistHandler) ExportCSV(c *gin.Context) {
    id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
    pl, _ := h.service.GetByID(uint(id))

    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))

    // UTF-8 BOM for Excel compatibility
    c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})

    writer := csv.NewWriter(c.Writer)
    writer.Comma = ';' // semicolon for Russian-locale Excel
    defer writer.Flush()

    isWarehouse := strings.ToLower(pl.Source) == "warehouse"
    var header []string
    if isWarehouse {
        header = []string{"Article", "Category", "Description", "Available", "Partnumbers", "Price, $", "Settings"}
    } else {
        header = []string{"Article", "Category", "Description", "Price, $", "Settings"}
    }
    writer.Write(header)

    h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error {
        for _, item := range items {
            writer.Write(buildRow(item, isWarehouse))
        }
        writer.Flush() // flush after each batch
        return nil
    })
}

2. Service Layer

func (s *Service) StreamItemsForExport(id uint, batchSize int, cb func([]models.PricelistItem) error) error {
    return s.repo.StreamItemsForExport(id, batchSize, cb)
}

3. Repository Layer

func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func([]models.PricelistItem) error) error {
    offset := 0
    for {
        var items []models.PricelistItem
        err := r.db.Table("qt_pricelist_items AS pi").
            Select("pi.*, COALESCE(l.lot_description, '') AS lot_description").
            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
        }
        if isWarehouse {
            r.enrichWarehouseItems(items) // adds qty, partnumbers
        }
        if err := callback(items); err != nil {
            return err
        }
        if len(items) < batchSize {
            break
        }
        offset += batchSize
    }
    return nil
}

Streaming (avoid loading all into memory)

// ❌ BAD:
var allItems []Item
db.Find(&allItems) // can OOM on millions of rows

// ✅ GOOD:
for offset := 0; ; offset += batchSize {
    var batch []Item
    db.Offset(offset).Limit(batchSize).Find(&batch)
    processBatch(batch)
    if len(batch) < batchSize {
        break
    }
}

Recommended batch size: 5001000.


JOIN instead of N+1

// ❌ N+1:
for _, item := range items {
    description := getLotDescription(item.LotName) // N queries
}

// ✅ 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")

UTF-8 BOM for Excel

// Excel on Windows requires BOM for correct UTF-8 display
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})

Semicolon delimiter for Excel (Russian locale)

writer := csv.NewWriter(c.Writer)
writer.Comma = ';'

Graceful error handling during streaming

// Cannot return JSON error after streaming has started
if err != nil {
    c.String(http.StatusInternalServerError, "Export failed: %v", err)
    return
}

Virtual Fields (GORM)

type PricelistItem struct {
    // Stored fields
    ID      uint    `gorm:"primaryKey"`
    LotName string  `gorm:"size:255"`
    Price   float64 `gorm:"type:decimal(12,2)"`

    // Stored category snapshot
    LotCategory    *string `gorm:"column:lot_category;size:50" json:"category,omitempty"`
    // Virtual: populated via JOIN
    LotDescription string   `gorm:"-:migration" json:"lot_description,omitempty"`
    // Virtual: populated programmatically
    AvailableQty   *float64 `gorm:"-" json:"available_qty,omitempty"`
    Partnumbers    []string `gorm:"-" json:"partnumbers,omitempty"`
}
  • gorm:"-:migration" — no DB column, but mapped on SELECT.
  • gorm:"-" — fully ignored in all DB operations.

CSV Export Checklist

  • HTTP headers: Content-Type, Content-Disposition
  • UTF-8 BOM {0xEF, 0xBB, 0xBF}
  • Semicolon delimiter ; for Russian-locale Excel
  • Streaming with batch processing (no full load into memory)
  • JOIN to avoid N+1 queries
  • writer.Flush() after each batch
  • Graceful error handling (no JSON after streaming starts)
  • Dynamic headers when needed
  • Conditional enrichment (warehouse vs estimate)