export: implement streaming CSV with Excel compatibility

Implement Phase 1 CSV Export Optimization:
- Replace buffering with true HTTP streaming (ToCSV writes to io.Writer)
- Add UTF-8 BOM (0xEF 0xBB 0xBF) for correct Cyrillic display in Excel
- Use semicolon (;) delimiter for Russian Excel locale
- Use comma (,) as decimal separator in numbers (100,50 instead of 100.50)
- Add graceful two-phase error handling:
  * Before streaming: return JSON errors for validation failures
  * During streaming: log errors only (HTTP 200 already sent)
- Add backward-compatible ToCSVBytes() helper
- Add GET /api/configs/:uuid/export route for configuration export

New tests (13 total):
- Service layer (7 tests):
  * UTF-8 BOM verification
  * Semicolon delimiter parsing
  * Total row formatting
  * Category sorting
  * Empty data handling
  * Backward compatibility wrapper
  * Writer error handling
- Handler layer (6 tests):
  * Successful CSV export with streaming
  * Invalid request validation
  * Empty items validation
  * Config export with proper headers
  * 404 for missing configs
  * Empty config validation

All tests passing, build verified.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-02-09 10:47:10 +03:00
parent 17969277e6
commit bca82f9dc0
5 changed files with 712 additions and 21 deletions

View File

@@ -4,6 +4,8 @@ import (
"bytes"
"encoding/csv"
"fmt"
"io"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
@@ -40,14 +42,21 @@ type ExportItem struct {
TotalPrice float64
}
func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
var buf bytes.Buffer
w := csv.NewWriter(&buf)
func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
// Write UTF-8 BOM for Excel compatibility
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return fmt.Errorf("failed to write BOM: %w", err)
}
csvWriter := csv.NewWriter(w)
// Use semicolon as delimiter for Russian Excel locale
csvWriter.Comma = ';'
defer csvWriter.Flush()
// Header
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
if err := w.Write(headers); err != nil {
return nil, err
if err := csvWriter.Write(headers); err != nil {
return fmt.Errorf("failed to write header: %w", err)
}
// Get category hierarchy for sorting
@@ -90,21 +99,35 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
item.Description,
item.Category,
fmt.Sprintf("%d", item.Quantity),
fmt.Sprintf("%.2f", item.UnitPrice),
fmt.Sprintf("%.2f", item.TotalPrice),
strings.ReplaceAll(fmt.Sprintf("%.2f", item.UnitPrice), ".", ","),
strings.ReplaceAll(fmt.Sprintf("%.2f", item.TotalPrice), ".", ","),
}
if err := w.Write(row); err != nil {
return nil, err
if err := csvWriter.Write(row); err != nil {
return fmt.Errorf("failed to write row: %w", err)
}
}
// Total row
if err := w.Write([]string{"", "", "", "", "ИТОГО:", fmt.Sprintf("%.2f", data.Total)}); err != nil {
return nil, err
totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
if err := csvWriter.Write([]string{"", "", "", "", "ИТОГО:", totalStr}); err != nil {
return fmt.Errorf("failed to write total row: %w", err)
}
w.Flush()
return buf.Bytes(), w.Error()
csvWriter.Flush()
if err := csvWriter.Error(); err != nil {
return fmt.Errorf("csv writer error: %w", err)
}
return nil
}
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes
func (s *ExportService) ToCSVBytes(data *ExportData) ([]byte, error) {
var buf bytes.Buffer
if err := s.ToCSV(&buf, data); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {