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 } type ProjectPricingExportOptions struct { IncludeLOT bool `json:"include_lot"` IncludeBOM bool `json:"include_bom"` IncludeEstimate bool `json:"include_estimate"` IncludeStock bool `json:"include_stock"` IncludeCompetitor bool `json:"include_competitor"` Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob" SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3 } func (o ProjectPricingExportOptions) saleMarkupFactor() float64 { if o.SaleMarkup > 0 { return o.SaleMarkup } return 1.3 } func (o ProjectPricingExportOptions) isDDP() bool { return strings.EqualFold(strings.TrimSpace(o.Basis), "ddp") } type ProjectPricingExportData struct { Configs []ProjectPricingExportConfig CreatedAt time.Time } type ProjectPricingExportConfig struct { Name string Article string Line int ServerCount int Rows []ProjectPricingExportRow } type ProjectPricingExportRow struct { LotDisplay string VendorPN string Description string Quantity int BOMTotal *float64 Estimate *float64 Stock *float64 Competitor *float64 } // 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 } func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) { 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([]ProjectPricingExportConfig, 0, len(sortedConfigs)) for i := range sortedConfigs { block, err := s.buildPricingExportBlock(&sortedConfigs[i], opts) if err != nil { return nil, err } blocks = append(blocks, block) } return &ProjectPricingExportData{ Configs: blocks, CreatedAt: time.Now(), }, nil } func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData, opts ProjectPricingExportOptions) error { 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() headers := pricingCSVHeaders(opts) if err := csvWriter.Write(headers); err != nil { return fmt.Errorf("failed to write pricing header: %w", err) } writeRows := opts.IncludeLOT || opts.IncludeBOM for _, cfg := range data.Configs { if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil { return fmt.Errorf("failed to write config summary row: %w", err) } if writeRows { for _, row := range cfg.Rows { if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil { return fmt.Errorf("failed to write pricing row: %w", err) } } } } csvWriter.Flush() if err := csvWriter.Error(); err != nil { return fmt.Errorf("csv writer error: %w", err) } return 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, } } func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts ProjectPricingExportOptions) (ProjectPricingExportConfig, error) { block := ProjectPricingExportConfig{ Name: cfg.Name, Article: cfg.Article, Line: cfg.Line, ServerCount: exportPositiveInt(cfg.ServerCount, 1), Rows: make([]ProjectPricingExportRow, 0), } if s.localDB == nil { for _, item := range cfg.Items { block.Rows = append(block.Rows, ProjectPricingExportRow{ LotDisplay: item.LotName, VendorPN: "—", Quantity: item.Quantity, Estimate: floatPtr(item.UnitPrice * float64(item.Quantity)), }) } return block, nil } localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID) if err != nil { localCfg = nil } priceMap := s.resolvePricingTotals(cfg, localCfg, opts) componentDescriptions := s.resolveLotDescriptions(cfg, localCfg) if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 { coveredLots := make(map[string]struct{}) for _, row := range localCfg.VendorSpec { rowMappings := normalizeLotMappings(row.LotMappings) for _, mapping := range rowMappings { coveredLots[mapping.LotName] = struct{}{} } description := strings.TrimSpace(row.Description) if description == "" && len(rowMappings) > 0 { description = componentDescriptions[rowMappings[0].LotName] } pricingRow := ProjectPricingExportRow{ LotDisplay: formatLotDisplay(rowMappings), VendorPN: row.VendorPartnumber, Description: description, Quantity: exportPositiveInt(row.Quantity, 1), BOMTotal: vendorRowTotal(row), Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }), Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }), Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }), } block.Rows = append(block.Rows, pricingRow) } for _, item := range cfg.Items { if item.LotName == "" { continue } if _, ok := coveredLots[item.LotName]; ok { continue } estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity) block.Rows = append(block.Rows, ProjectPricingExportRow{ LotDisplay: item.LotName, VendorPN: "—", Description: componentDescriptions[item.LotName], Quantity: exportPositiveInt(item.Quantity, 1), Estimate: estimate, Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity), Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity), }) } if opts.isDDP() { applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) } return block, nil } for _, item := range cfg.Items { if item.LotName == "" { continue } estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity) block.Rows = append(block.Rows, ProjectPricingExportRow{ LotDisplay: item.LotName, VendorPN: "—", Description: componentDescriptions[item.LotName], Quantity: exportPositiveInt(item.Quantity, 1), Estimate: estimate, Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity), Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity), }) } if opts.isDDP() { applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) } return block, nil } func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) { for i := range rows { rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor) rows[i].Stock = scaleFloatPtr(rows[i].Stock, factor) rows[i].Competitor = scaleFloatPtr(rows[i].Competitor, factor) } } func scaleFloatPtr(v *float64, factor float64) *float64 { if v == nil { return nil } result := *v * factor return &result } // 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] } } } } type pricingLevels struct { Estimate *float64 Stock *float64 Competitor *float64 } func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, opts ProjectPricingExportOptions) map[string]pricingLevels { result := map[string]pricingLevels{} lots := collectPricingLots(cfg, localCfg, opts.IncludeBOM) if len(lots) == 0 || s.localDB == nil { return result } estimateID := cfg.PricelistID if estimateID == nil || *estimateID == 0 { if latest, err := s.localDB.GetLatestLocalPricelistBySource("estimate"); err == nil && latest != nil { estimateID = &latest.ServerID } } var warehouseID *uint var competitorID *uint if localCfg != nil { warehouseID = localCfg.WarehousePricelistID competitorID = localCfg.CompetitorPricelistID } if warehouseID == nil || *warehouseID == 0 { if latest, err := s.localDB.GetLatestLocalPricelistBySource("warehouse"); err == nil && latest != nil { warehouseID = &latest.ServerID } } if competitorID == nil || *competitorID == 0 { if latest, err := s.localDB.GetLatestLocalPricelistBySource("competitor"); err == nil && latest != nil { competitorID = &latest.ServerID } } for _, lot := range lots { level := pricingLevels{} level.Estimate = s.lookupPricePointer(estimateID, lot) level.Stock = s.lookupPricePointer(warehouseID, lot) level.Competitor = s.lookupPricePointer(competitorID, lot) result[lot] = level } return result } func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 { if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" { return nil } localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID) if err != nil { return nil } price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName) if err != nil || price <= 0 { return nil } return floatPtr(price) } func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string { lots := collectPricingLots(cfg, localCfg, true) result := make(map[string]string, len(lots)) if s.localDB == nil { return result } for _, lot := range lots { component, err := s.localDB.GetLocalComponent(lot) if err != nil { continue } result[lot] = component.LotDescription } return result } func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string { seen := map[string]struct{}{} out := make([]string, 0) if includeBOM && localCfg != nil { for _, row := range localCfg.VendorSpec { for _, mapping := range normalizeLotMappings(row.LotMappings) { if _, ok := seen[mapping.LotName]; ok { continue } seen[mapping.LotName] = struct{}{} out = append(out, mapping.LotName) } } } for _, item := range cfg.Items { lot := strings.TrimSpace(item.LotName) if lot == "" { continue } if _, ok := seen[lot]; ok { continue } seen[lot] = struct{}{} out = append(out, lot) } return out } func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping { if len(mappings) == 0 { return nil } out := make([]localdb.VendorSpecLotMapping, 0, len(mappings)) for _, mapping := range mappings { lot := strings.TrimSpace(mapping.LotName) if lot == "" { continue } qty := mapping.QuantityPerPN if qty < 1 { qty = 1 } out = append(out, localdb.VendorSpecLotMapping{ LotName: lot, QuantityPerPN: qty, }) } return out } func vendorRowTotal(row localdb.VendorSpecItem) *float64 { if row.TotalPrice != nil { return floatPtr(*row.TotalPrice) } if row.UnitPrice == nil { return nil } return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1))) } func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 { if len(mappings) == 0 { return nil } total := 0.0 hasValue := false qty := exportPositiveInt(pnQty, 1) for _, mapping := range mappings { price := selector(priceMap[mapping.LotName]) if price == nil || *price <= 0 { continue } total += *price * float64(qty*mapping.QuantityPerPN) hasValue = true } if !hasValue { return nil } return floatPtr(total) } func totalForUnitPrice(unitPrice *float64, quantity int) *float64 { if unitPrice == nil || *unitPrice <= 0 { return nil } total := *unitPrice * float64(exportPositiveInt(quantity, 1)) return &total } func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quantity int) *float64 { if estimatePrice != nil && *estimatePrice > 0 { return totalForUnitPrice(estimatePrice, quantity) } if fallbackUnitPrice <= 0 { return nil } total := fallbackUnitPrice * float64(maxInt(quantity, 1)) return &total } func pricingCSVHeaders(opts ProjectPricingExportOptions) []string { headers := make([]string, 0, 8) headers = append(headers, "Line Item") if opts.IncludeLOT { headers = append(headers, "LOT") } headers = append(headers, "PN вендора", "Описание", "Кол-во") if opts.IncludeBOM { headers = append(headers, "BOM") } if opts.IncludeEstimate { headers = append(headers, "Estimate") } if opts.IncludeStock { headers = append(headers, "Stock") } if opts.IncludeCompetitor { headers = append(headers, "Конкуренты") } return headers } func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string { record := make([]string, 0, 8) record = append(record, "") if opts.IncludeLOT { record = append(record, emptyDash(row.LotDisplay)) } record = append(record, emptyDash(row.VendorPN), emptyDash(row.Description), fmt.Sprintf("%d", exportPositiveInt(row.Quantity, 1)), ) if opts.IncludeBOM { record = append(record, formatMoneyValue(row.BOMTotal)) } if opts.IncludeEstimate { record = append(record, formatMoneyValue(row.Estimate)) } if opts.IncludeStock { record = append(record, formatMoneyValue(row.Stock)) } if opts.IncludeCompetitor { record = append(record, formatMoneyValue(row.Competitor)) } return record } func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string { record := make([]string, 0, 8) record = append(record, fmt.Sprintf("%d", cfg.Line)) if opts.IncludeLOT { record = append(record, "") } record = append(record, emptyDash(cfg.Article), emptyDash(cfg.Name), fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)), ) if opts.IncludeBOM { record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.BOMTotal }))) } if opts.IncludeEstimate { record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Estimate }))) } if opts.IncludeStock { record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Stock }))) } if opts.IncludeCompetitor { record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor }))) } return record } func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string { switch len(mappings) { case 0: return "н/д" case 1: return mappings[0].LotName default: return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1) } } func formatMoneyValue(value *float64) string { if value == nil { return "—" } n := math.Round(*value*100) / 100 sign := "" if n < 0 { sign = "-" n = -n } whole := int64(n) fraction := int(math.Round((n - float64(whole)) * 100)) if fraction == 100 { whole++ fraction = 0 } return fmt.Sprintf("%s%s,%02d", sign, formatIntWithSpace(whole), fraction) } func emptyDash(value string) string { if strings.TrimSpace(value) == "" { return "—" } return value } func sumPricingColumn(rows []ProjectPricingExportRow, selector func(ProjectPricingExportRow) *float64) *float64 { total := 0.0 hasValue := false for _, row := range rows { value := selector(row) if value == nil { continue } total += *value hasValue = true } if !hasValue { return nil } return floatPtr(total) } func floatPtr(value float64) *float64 { v := value return &v } func exportPositiveInt(value, fallback int) int { if value < 1 { return fallback } return value } // 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() }