Files
QuoteForge/internal/services/export.go
Mikhail Chusavitin bca82f9dc0 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>
2026-02-09 10:47:10 +03:00

171 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"bytes"
"encoding/csv"
"fmt"
"io"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
type ExportService struct {
config config.ExportConfig
categoryRepo *repository.CategoryRepository
}
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository) *ExportService {
return &ExportService{
config: cfg,
categoryRepo: categoryRepo,
}
}
type ExportData struct {
Name string
Items []ExportItem
Total float64
Notes string
CreatedAt time.Time
}
type ExportItem struct {
LotName string
Description string
Category string
Quantity int
UnitPrice float64
TotalPrice float64
}
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 := csvWriter.Write(headers); err != nil {
return fmt.Errorf("failed to write header: %w", err)
}
// Get category hierarchy for sorting
categoryOrder := make(map[string]int)
if s.categoryRepo != nil {
categories, err := s.categoryRepo.GetAll()
if err == nil {
for _, cat := range categories {
categoryOrder[cat.Code] = cat.DisplayOrder
}
}
}
// Sort items by category display order
sortedItems := make([]ExportItem, len(data.Items))
copy(sortedItems, data.Items)
// Sort using category display order (items without category go to the end)
for i := 0; i < len(sortedItems)-1; i++ {
for j := i + 1; j < len(sortedItems); j++ {
orderI, hasI := categoryOrder[sortedItems[i].Category]
orderJ, hasJ := categoryOrder[sortedItems[j].Category]
// Items without category go to the end
if !hasI && hasJ {
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
} else if hasI && hasJ {
// Both have categories, sort by display order
if orderI > orderJ {
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
}
}
}
}
// Items
for _, item := range sortedItems {
row := []string{
item.LotName,
item.Description,
item.Category,
fmt.Sprintf("%d", item.Quantity),
strings.ReplaceAll(fmt.Sprintf("%.2f", item.UnitPrice), ".", ","),
strings.ReplaceAll(fmt.Sprintf("%.2f", item.TotalPrice), ".", ","),
}
if err := csvWriter.Write(row); err != nil {
return fmt.Errorf("failed to write row: %w", err)
}
}
// Total row
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)
}
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 {
items := make([]ExportItem, len(config.Items))
var total float64
for i, item := range config.Items {
itemTotal := item.UnitPrice * float64(item.Quantity)
// Получаем информацию о компоненте для заполнения категории
componentView, err := componentService.GetByLotName(item.LotName)
if err != nil {
// Если не удалось получить информацию о компоненте, используем только основные данные
items[i] = ExportItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
TotalPrice: itemTotal,
}
} else {
items[i] = ExportItem{
LotName: item.LotName,
Description: componentView.Description,
Category: componentView.Category,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
TotalPrice: itemTotal,
}
}
total += itemTotal
}
return &ExportData{
Name: config.Name,
Items: items,
Total: total,
Notes: config.Notes,
CreatedAt: config.CreatedAt,
}
}