package services import ( "bytes" "encoding/csv" "fmt" "io" "math" "sort" "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" ) type ExportService struct { config config.ExportConfig categoryRepo *repository.CategoryRepository localDB *localdb.LocalDB } func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService { return &ExportService{ config: cfg, categoryRepo: categoryRepo, localDB: local, } } // ExportItem represents a single component in an export block. type ExportItem struct { LotName string Description string Category string Quantity int UnitPrice float64 TotalPrice float64 } // ConfigExportBlock represents one configuration (server) in the export. type ConfigExportBlock struct { Article string Line int ServerCount int UnitPrice float64 // sum of component prices for one server Items []ExportItem } // ProjectExportData holds all configuration blocks for a project-level export. type ProjectExportData struct { Configs []ConfigExportBlock CreatedAt time.Time } // ToCSV writes project export data in the new structured CSV format. // // Format: // // Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total) // 10;;DL380-ARTICLE;;;10;10470;104 700 // ;;MB_INTEL_...;;1;;2074,5; // ... // (empty row) // 20;;DL380-ARTICLE-2;;;2;10470;20 940 // ... func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) 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) csvWriter.Comma = ';' defer csvWriter.Flush() // Header headers := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"} 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 } } } for i, block := range data.Configs { lineNo := block.Line if lineNo <= 0 { lineNo = (i + 1) * 10 } serverCount := block.ServerCount if serverCount < 1 { serverCount = 1 } totalPrice := block.UnitPrice * float64(serverCount) // Server summary row serverRow := []string{ fmt.Sprintf("%d", lineNo), // Line "", // Type block.Article, // p/n "", // Description "", // Qty (1 pcs.) fmt.Sprintf("%d", serverCount), // Qty (total) formatPriceInt(block.UnitPrice), // Price (1 pcs.) formatPriceWithSpace(totalPrice), // Price (total) } if err := csvWriter.Write(serverRow); err != nil { return fmt.Errorf("failed to write server row: %w", err) } // Sort items by category display order sortedItems := make([]ExportItem, len(block.Items)) copy(sortedItems, block.Items) sortItemsByCategory(sortedItems, categoryOrder) // Component rows for _, item := range sortedItems { componentRow := []string{ "", // Line item.Category, // Type item.LotName, // p/n "", // Description fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.) "", // Qty (total) formatPriceComma(item.UnitPrice), // Price (1 pcs.) "", // Price (total) } if err := csvWriter.Write(componentRow); err != nil { return fmt.Errorf("failed to write component row: %w", err) } } // Empty separator row between blocks (skip after last) if i < len(data.Configs)-1 { if err := csvWriter.Write([]string{"", "", "", "", "", "", "", ""}); err != nil { return fmt.Errorf("failed to write separator 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 *ProjectExportData) ([]byte, error) { var buf bytes.Buffer if err := s.ToCSV(&buf, data); err != nil { return nil, err } return buf.Bytes(), nil } // ConfigToExportData converts a single configuration into ProjectExportData. func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData { block := s.buildExportBlock(cfg) return &ProjectExportData{ Configs: []ConfigExportBlock{block}, CreatedAt: cfg.CreatedAt, } } // ProjectToExportData converts multiple configurations into ProjectExportData. func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData { sortedConfigs := make([]models.Configuration, len(configs)) copy(sortedConfigs, configs) sort.Slice(sortedConfigs, func(i, j int) bool { leftLine := sortedConfigs[i].Line rightLine := sortedConfigs[j].Line if leftLine <= 0 { leftLine = int(^uint(0) >> 1) } if rightLine <= 0 { rightLine = int(^uint(0) >> 1) } if leftLine != rightLine { return leftLine < rightLine } if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) { return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt) } return sortedConfigs[i].UUID > sortedConfigs[j].UUID }) blocks := make([]ConfigExportBlock, 0, len(configs)) for i := range sortedConfigs { blocks = append(blocks, s.buildExportBlock(&sortedConfigs[i])) } return &ProjectExportData{ Configs: blocks, CreatedAt: time.Now(), } } func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock { // Batch-fetch categories from local data (pricelist items → local_components fallback) lotNames := make([]string, len(cfg.Items)) for i, item := range cfg.Items { lotNames[i] = item.LotName } categories := s.resolveCategories(cfg.PricelistID, lotNames) items := make([]ExportItem, len(cfg.Items)) var unitTotal float64 for i, item := range cfg.Items { itemTotal := item.UnitPrice * float64(item.Quantity) items[i] = ExportItem{ LotName: item.LotName, Category: categories[item.LotName], Quantity: item.Quantity, UnitPrice: item.UnitPrice, TotalPrice: itemTotal, } unitTotal += itemTotal } serverCount := cfg.ServerCount if serverCount < 1 { serverCount = 1 } return ConfigExportBlock{ Article: cfg.Article, Line: cfg.Line, ServerCount: serverCount, UnitPrice: unitTotal, Items: items, } } // resolveCategories returns lot_name → category map. // Primary source: pricelist items (lot_category). Fallback: local_components table. func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string { if len(lotNames) == 0 || s.localDB == nil { return map[string]string{} } categories := make(map[string]string, len(lotNames)) // Primary: pricelist items if pricelistID != nil && *pricelistID > 0 { if cats, err := s.localDB.GetLocalLotCategoriesByServerPricelistID(*pricelistID, lotNames); err == nil { for lot, cat := range cats { if strings.TrimSpace(cat) != "" { categories[lot] = cat } } } } // Fallback: local_components for any still missing var missing []string for _, lot := range lotNames { if categories[lot] == "" { missing = append(missing, lot) } } if len(missing) > 0 { if fallback, err := s.localDB.GetLocalComponentCategoriesByLotNames(missing); err == nil { for lot, cat := range fallback { if strings.TrimSpace(cat) != "" { categories[lot] = cat } } } } return categories } // sortItemsByCategory sorts items by category display order (items without category go to the end). func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) { for i := 0; i < len(items)-1; i++ { for j := i + 1; j < len(items); j++ { orderI, hasI := categoryOrder[items[i].Category] orderJ, hasJ := categoryOrder[items[j].Category] if !hasI && hasJ { items[i], items[j] = items[j], items[i] } else if hasI && hasJ && orderI > orderJ { items[i], items[j] = items[j], items[i] } } } } // formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5"). // Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown. func formatPriceComma(value float64) string { if value == math.Trunc(value) { return fmt.Sprintf("%.0f", value) } s := fmt.Sprintf("%.2f", value) s = strings.ReplaceAll(s, ".", ",") // Trim trailing zero: "2074,50" -> "2074,5" s = strings.TrimRight(s, "0") s = strings.TrimRight(s, ",") return s } // formatPriceInt formats price as integer (rounded), no decimal. func formatPriceInt(value float64) string { return fmt.Sprintf("%.0f", math.Round(value)) } // formatPriceWithSpace formats a price as an integer with space as thousands separator (e.g., "104 700"). func formatPriceWithSpace(value float64) string { intVal := int64(math.Round(value)) if intVal < 0 { return "-" + formatIntWithSpace(-intVal) } return formatIntWithSpace(intVal) } func formatIntWithSpace(n int64) string { s := fmt.Sprintf("%d", n) if len(s) <= 3 { return s } var result strings.Builder remainder := len(s) % 3 if remainder > 0 { result.WriteString(s[:remainder]) } for i := remainder; i < len(s); i += 3 { if result.Len() > 0 { result.WriteByte(' ') } result.WriteString(s[i : i+3]) } return result.String() }