198 lines
5.1 KiB
Markdown
198 lines
5.1 KiB
Markdown
# Code Patterns
|
||
|
||
## CSV Export (3-layer architecture)
|
||
|
||
Used for pricelist export.
|
||
|
||
**Implementation**:
|
||
- Handler: `internal/handlers/pricelist.go` — `ExportCSV`
|
||
- Service: `internal/services/pricelist/service.go` — `StreamItemsForExport`
|
||
- Repository: `internal/repository/pricelist.go` — `StreamItemsForExport`
|
||
- Models: `internal/models/pricelist.go`
|
||
|
||
---
|
||
|
||
### 1. Handler Layer
|
||
|
||
```go
|
||
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
|
||
|
||
```go
|
||
func (s *Service) StreamItemsForExport(id uint, batchSize int, cb func([]models.PricelistItem) error) error {
|
||
return s.repo.StreamItemsForExport(id, batchSize, cb)
|
||
}
|
||
```
|
||
|
||
### 3. Repository Layer
|
||
|
||
```go
|
||
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)
|
||
|
||
```go
|
||
// ❌ 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**: 500–1000.
|
||
|
||
---
|
||
|
||
## JOIN instead of N+1
|
||
|
||
```go
|
||
// ❌ 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
|
||
|
||
```go
|
||
// Excel on Windows requires BOM for correct UTF-8 display
|
||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||
```
|
||
|
||
---
|
||
|
||
## Semicolon delimiter for Excel (Russian locale)
|
||
|
||
```go
|
||
writer := csv.NewWriter(c.Writer)
|
||
writer.Comma = ';'
|
||
```
|
||
|
||
---
|
||
|
||
## Graceful error handling during streaming
|
||
|
||
```go
|
||
// Cannot return JSON error after streaming has started
|
||
if err != nil {
|
||
c.String(http.StatusInternalServerError, "Export failed: %v", err)
|
||
return
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Virtual Fields (GORM)
|
||
|
||
```go
|
||
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)
|