package services import ( "bytes" "encoding/csv" "encoding/xml" "fmt" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" "github.com/google/uuid" "gorm.io/gorm" ) type VendorWorkspaceImportResult struct { Imported int `json:"imported"` Project *models.Project `json:"project,omitempty"` Configs []VendorWorkspaceImportedConfig `json:"configs"` } type VendorWorkspaceImportedConfig struct { UUID string `json:"uuid"` Name string `json:"name"` ServerCount int `json:"server_count"` ServerModel string `json:"server_model,omitempty"` Rows int `json:"rows"` } type importedWorkspace struct { SourceFormat string SourceDocID string SourceFileName string CurrencyCode string Configurations []importedConfiguration } type importedConfiguration struct { GroupID string Name string Line int ServerCount int ServerModel string Article string CurrencyCode string Rows []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur) DirectItems localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV) TotalPrice *float64 } type groupedItem struct { order int row cfxmlProductLineItem } type cfxmlDocument struct { XMLName xml.Name `xml:"CFXML"` ThisDocumentIdentifier cfxmlDocumentIdentifier `xml:"thisDocumentIdentifier"` CFData cfxmlData `xml:"CFData"` } type cfxmlDocumentIdentifier struct { ProprietaryDocumentIdentifier string `xml:"ProprietaryDocumentIdentifier"` } type cfxmlData struct { ProprietaryInformation []cfxmlProprietaryInformation `xml:"ProprietaryInformation"` ProductLineItems []cfxmlProductLineItem `xml:"ProductLineItem"` } type cfxmlProprietaryInformation struct { Name string `xml:"Name"` Value string `xml:"Value"` } type cfxmlProductLineItem struct { ProductLineNumber string `xml:"ProductLineNumber"` ItemNo string `xml:"ItemNo"` TransactionType string `xml:"TransactionType"` ProprietaryGroupIdentifier string `xml:"ProprietaryGroupIdentifier"` ConfigurationGroupLineNumberReference string `xml:"ConfigurationGroupLineNumberReference"` Quantity string `xml:"Quantity"` ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"` UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"` ProductSubLineItems []cfxmlProductSubLineItem `xml:"ProductSubLineItem"` } type cfxmlProductSubLineItem struct { LineNumber string `xml:"LineNumber"` TransactionType string `xml:"TransactionType"` Quantity string `xml:"Quantity"` ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"` UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"` } type cfxmlProductIdentification struct { PartnerProductIdentification cfxmlPartnerProductIdentification `xml:"PartnerProductIdentification"` } type cfxmlPartnerProductIdentification struct { ProprietaryProductIdentifier string `xml:"ProprietaryProductIdentifier"` ProprietaryProductChar string `xml:"ProprietaryProductChar"` ProductCharacter string `xml:"ProductCharacter"` ProductDescription string `xml:"ProductDescription"` ProductName string `xml:"ProductName"` ProductTypeCode string `xml:"ProductTypeCode"` } type cfxmlUnitListPrice struct { FinancialAmount cfxmlFinancialAmount `xml:"FinancialAmount"` } type cfxmlFinancialAmount struct { GlobalCurrencyCode string `xml:"GlobalCurrencyCode"` MonetaryAmount string `xml:"MonetaryAmount"` } func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID string, sourceFileName string, data []byte, ownerUsername string) (*VendorWorkspaceImportResult, error) { project, err := s.localDB.GetProjectByUUID(projectUUID) if err != nil { return nil, ErrProjectNotFound } var workspace *importedWorkspace switch { case IsCFXMLWorkspace(data): workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName)) case IsQuoteForgeCSV(data): workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName)) case IsInspurBOM(data): workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName)) case IsTextBOM(data): workspace, err = parseTextBOM(data, filepath.Base(sourceFileName)) default: return nil, fmt.Errorf("unsupported vendor export format") } if err != nil { return nil, err } result := &VendorWorkspaceImportResult{ Imported: 0, Project: localdb.LocalToProject(project), Configs: make([]VendorWorkspaceImportedConfig, 0, len(workspace.Configurations)), } err = s.localDB.DB().Transaction(func(tx *gorm.DB) error { bookRepo := repository.NewPartnumberBookRepository(tx) for _, imported := range workspace.Configurations { now := time.Now() cfgUUID := uuid.NewString() var groupRows localdb.VendorSpec var items localdb.LocalConfigItems var totalPrice *float64 var estimatePricelistID *uint if len(imported.DirectItems) > 0 { items = imported.DirectItems estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate") if estimatePricelist != nil { estimatePricelistID = &estimatePricelist.ServerID } val := items.Total() * float64(maxInt(imported.ServerCount, 1)) totalPrice = &val } else { var prepErr error groupRows, items, totalPrice, estimatePricelistID, prepErr = s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo) if prepErr != nil { return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, prepErr) } } localCfg := &localdb.LocalConfiguration{ UUID: cfgUUID, ProjectUUID: &projectUUID, IsActive: true, Name: imported.Name, Items: items, TotalPrice: totalPrice, ServerCount: imported.ServerCount, ServerModel: imported.ServerModel, Article: imported.Article, PricelistID: estimatePricelistID, VendorSpec: groupRows, CreatedAt: now, UpdatedAt: now, SyncStatus: "pending", OriginalUsername: ownerUsername, } if err := s.createWithVersionTx(tx, localCfg, ownerUsername); err != nil { return fmt.Errorf("import configuration group %s: %w", imported.GroupID, err) } result.Imported++ result.Configs = append(result.Configs, VendorWorkspaceImportedConfig{ UUID: localCfg.UUID, Name: localCfg.Name, ServerCount: localCfg.ServerCount, ServerModel: localCfg.ServerModel, Rows: len(localCfg.VendorSpec), }) } return nil }) if err != nil { return nil, err } return result, nil } func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb.VendorSpecItem, serverCount int, bookRepo *repository.PartnumberBookRepository) (localdb.VendorSpec, localdb.LocalConfigItems, *float64, *uint, error) { resolver := NewVendorSpecResolver(bookRepo) resolved, err := resolver.Resolve(append([]localdb.VendorSpecItem(nil), rows...)) if err != nil { return nil, nil, nil, nil, err } canonical := make(localdb.VendorSpec, 0, len(resolved)) for _, row := range resolved { if len(row.LotMappings) == 0 && strings.TrimSpace(row.ResolvedLotName) != "" { row.LotMappings = []localdb.VendorSpecLotMapping{ {LotName: strings.TrimSpace(row.ResolvedLotName), QuantityPerPN: 1}, } } row.LotMappings = normalizeImportedLotMappings(row.LotMappings) row.ResolvedLotName = "" row.ResolutionSource = "" row.ManualLotSuggestion = "" row.LotQtyPerPN = 0 row.LotAllocations = nil canonical = append(canonical, row) } estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate") var serverPricelistID *uint if estimatePricelist != nil { serverPricelistID = &estimatePricelist.ServerID } items := aggregateVendorSpecToItems(canonical, estimatePricelist, s.localDB) totalValue := items.Total() if serverCount > 1 { totalValue *= float64(serverCount) } totalPrice := &totalValue return canonical, items, totalPrice, serverPricelistID, nil } func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *localdb.LocalPricelist, local *localdb.LocalDB) localdb.LocalConfigItems { if len(spec) == 0 { return localdb.LocalConfigItems{} } lotMap := make(map[string]int) order := make([]string, 0) for _, row := range spec { for _, mapping := range normalizeImportedLotMappings(row.LotMappings) { if _, exists := lotMap[mapping.LotName]; !exists { order = append(order, mapping.LotName) } lotMap[mapping.LotName] += row.Quantity * mapping.QuantityPerPN } } sort.Strings(order) var priceMap map[string]float64 if estimatePricelist != nil && local != nil && len(order) > 0 { priceMap, _ = local.GetLocalPricesForLots(estimatePricelist.ID, order) } items := make(localdb.LocalConfigItems, 0, len(order)) for _, lotName := range order { unitPrice := 0.0 if priceMap != nil { unitPrice = priceMap[lotName] } items = append(items, localdb.LocalConfigItem{ LotName: lotName, Quantity: lotMap[lotName], UnitPrice: unitPrice, }) } return items } func normalizeImportedLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping { if len(in) == 0 { return nil } merged := make(map[string]int, len(in)) order := make([]string, 0, len(in)) for _, mapping := range in { lot := strings.TrimSpace(mapping.LotName) if lot == "" { continue } qty := mapping.QuantityPerPN if qty < 1 { qty = 1 } if _, exists := merged[lot]; !exists { order = append(order, lot) } merged[lot] += qty } out := make([]localdb.VendorSpecLotMapping, 0, len(order)) for _, lot := range order { out = append(out, localdb.VendorSpecLotMapping{ LotName: lot, QuantityPerPN: merged[lot], }) } if len(out) == 0 { return nil } return out } func parseCFXMLWorkspace(data []byte, sourceFileName string) (*importedWorkspace, error) { var doc cfxmlDocument if err := xml.Unmarshal(data, &doc); err != nil { return nil, fmt.Errorf("parse CFXML workspace: %w", err) } if doc.XMLName.Local != "CFXML" { return nil, fmt.Errorf("unsupported workspace root: %s", doc.XMLName.Local) } if len(doc.CFData.ProductLineItems) == 0 { return nil, fmt.Errorf("CFXML workspace has no ProductLineItem rows") } workspace := &importedWorkspace{ SourceFormat: "CFXML", SourceDocID: strings.TrimSpace(doc.ThisDocumentIdentifier.ProprietaryDocumentIdentifier), SourceFileName: sourceFileName, CurrencyCode: detectWorkspaceCurrency(doc.CFData.ProprietaryInformation, doc.CFData.ProductLineItems), } type groupBucket struct { order int items []groupedItem } groupOrder := make([]string, 0) groups := make(map[string]*groupBucket) for idx, item := range doc.CFData.ProductLineItems { groupID := strings.TrimSpace(item.ProprietaryGroupIdentifier) if groupID == "" { groupID = firstNonEmpty(strings.TrimSpace(item.ProductLineNumber), strings.TrimSpace(item.ItemNo), fmt.Sprintf("group-%d", idx+1)) } bucket := groups[groupID] if bucket == nil { bucket = &groupBucket{order: idx} groups[groupID] = bucket groupOrder = append(groupOrder, groupID) } bucket.items = append(bucket.items, groupedItem{order: idx, row: item}) } for lineIdx, groupID := range groupOrder { bucket := groups[groupID] if bucket == nil || len(bucket.items) == 0 { continue } primary := pickPrimaryTopLevelRow(bucket.items) serverCount := maxInt(parseInt(primary.row.Quantity), 1) rows := make([]localdb.VendorSpecItem, 0, len(bucket.items)*4) sortOrder := 10 for _, item := range bucket.items { topRow := vendorSpecItemFromTopLevel(item.row, serverCount, sortOrder) if topRow != nil { rows = append(rows, *topRow) sortOrder += 10 } for _, sub := range item.row.ProductSubLineItems { subRow := vendorSpecItemFromSubLine(sub, sortOrder) if subRow == nil { continue } rows = append(rows, *subRow) sortOrder += 10 } } total := sumVendorSpecRows(rows, serverCount) name := strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductName) if name == "" { name = strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription) } if name == "" { name = fmt.Sprintf("Imported config %d", lineIdx+1) } workspace.Configurations = append(workspace.Configurations, importedConfiguration{ GroupID: groupID, Name: name, Line: (lineIdx + 1) * 10, ServerCount: serverCount, ServerModel: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription), Article: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier), CurrencyCode: workspace.CurrencyCode, Rows: rows, TotalPrice: total, }) } if len(workspace.Configurations) == 0 { return nil, fmt.Errorf("CFXML workspace has no importable configuration groups") } return workspace, nil } func detectWorkspaceCurrency(meta []cfxmlProprietaryInformation, rows []cfxmlProductLineItem) string { for _, item := range meta { if strings.EqualFold(strings.TrimSpace(item.Name), "Currencies") { value := strings.TrimSpace(item.Value) if value != "" { return value } } } for _, row := range rows { code := strings.TrimSpace(row.UnitListPrice.FinancialAmount.GlobalCurrencyCode) if code != "" { return code } } return "" } func pickPrimaryTopLevelRow(items []groupedItem) groupedItem { best := items[0] bestScore := primaryScore(best.row) for _, item := range items[1:] { score := primaryScore(item.row) if score > bestScore { best = item bestScore = score continue } if score == bestScore && compareLineNumbers(item.row.ProductLineNumber, best.row.ProductLineNumber) < 0 { best = item } } return best } func primaryScore(row cfxmlProductLineItem) int { score := len(row.ProductSubLineItems) if strings.EqualFold(strings.TrimSpace(row.ProductIdentification.PartnerProductIdentification.ProductTypeCode), "Hardware") { score += 100000 } return score } func compareLineNumbers(left, right string) int { li := parseInt(left) ri := parseInt(right) switch { case li < ri: return -1 case li > ri: return 1 default: return strings.Compare(left, right) } } func vendorSpecItemFromTopLevel(item cfxmlProductLineItem, serverCount int, sortOrder int) *localdb.VendorSpecItem { code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier) desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription) if code == "" && desc == "" { return nil } qty := normalizeTopLevelQuantity(item.Quantity, serverCount) unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount) return &localdb.VendorSpecItem{ SortOrder: sortOrder, VendorPartnumber: code, Quantity: qty, Description: desc, UnitPrice: unitPrice, TotalPrice: totalPrice(unitPrice, qty), } } func vendorSpecItemFromSubLine(item cfxmlProductSubLineItem, sortOrder int) *localdb.VendorSpecItem { code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier) desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription) if code == "" && desc == "" { return nil } qty := maxInt(parseInt(item.Quantity), 1) unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount) return &localdb.VendorSpecItem{ SortOrder: sortOrder, VendorPartnumber: code, Quantity: qty, Description: desc, UnitPrice: unitPrice, TotalPrice: totalPrice(unitPrice, qty), } } func sumVendorSpecRows(rows []localdb.VendorSpecItem, serverCount int) *float64 { total := 0.0 hasTotal := false for _, row := range rows { if row.TotalPrice == nil { continue } total += *row.TotalPrice hasTotal = true } if !hasTotal { return nil } if serverCount > 1 { total *= float64(serverCount) } return &total } func totalPrice(unitPrice *float64, qty int) *float64 { if unitPrice == nil { return nil } total := *unitPrice * float64(qty) return &total } func parseOptionalFloat(raw string) *float64 { trimmed := strings.TrimSpace(raw) if trimmed == "" { return nil } value, err := strconv.ParseFloat(trimmed, 64) if err != nil { return nil } return &value } func parseInt(raw string) int { trimmed := strings.TrimSpace(raw) if trimmed == "" { return 0 } value, err := strconv.Atoi(trimmed) if err != nil { return 0 } return value } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func maxInt(value, floor int) int { if value < floor { return floor } return value } func normalizeTopLevelQuantity(raw string, serverCount int) int { qty := maxInt(parseInt(raw), 1) if serverCount <= 1 { return qty } if qty%serverCount == 0 { return maxInt(qty/serverCount, 1) } return qty } func IsCFXMLWorkspace(data []byte) bool { return bytes.Contains(data, []byte("")) || bytes.Contains(data, []byte(" 0 && allDigits(suffix) { return true } } return false } func allDigits(b []byte) bool { if len(b) == 0 { return false } for _, c := range b { if c < '0' || c > '9' { return false } } return true } func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, error) { lines := strings.Split(string(data), "\n") rows := make([]localdb.VendorSpecItem, 0, len(lines)) sortOrder := 10 for _, raw := range lines { line := strings.TrimSpace(raw) if line == "" { continue } line = strings.TrimPrefix(line, "|") line = strings.TrimSpace(line) if line == "" { continue } pn := line qty := 1 if idx := strings.LastIndex(line, "*"); idx > 0 { suffix := strings.TrimSpace(line[idx+1:]) if n, err := strconv.Atoi(suffix); err == nil && n > 0 { pn = strings.TrimSpace(line[:idx]) qty = n } } if pn == "" { continue } rows = append(rows, localdb.VendorSpecItem{ SortOrder: sortOrder, VendorPartnumber: pn, Quantity: qty, }) sortOrder += 10 } if len(rows) == 0 { return nil, fmt.Errorf("Inspur BOM has no importable rows") } name := strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName)) if name == "" { name = "Inspur Import" } return &importedWorkspace{ SourceFormat: "Inspur", SourceFileName: sourceFileName, Configurations: []importedConfiguration{ { GroupID: "inspur-0", Name: name, Line: 10, ServerCount: 1, Rows: rows, }, }, }, nil } // textBOMItemLine matches a human-readable BOM line of the form // " - шт." where the separator may be a hyphen, // en-dash or em-dash and the quantity may have an optional space before "шт". // The quantity anchor at the end keeps internal hyphens/digits in the // description (e.g. "8-GPU-2304GB") from being mistaken for the separator. var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`) // textBOMHeaderLine matches the configuration header "Сервер , в составе:". var textBOMHeaderLine = regexp.MustCompile(`(?i)^\s*сервер\s+(.+?)\s*,\s*в\s+составе`) // IsTextBOM reports whether data looks like a human-readable Russian text BOM, // i.e. it contains at least one " - шт." line. func IsTextBOM(data []byte) bool { for _, raw := range strings.Split(string(data), "\n") { if textBOMItemLine.MatchString(strings.TrimSpace(raw)) { return true } } return false } // parseTextBOM parses a human-readable Russian text BOM into a single configuration. // The optional "Сервер , в составе:" header provides the configuration name and // server model. Each " - шт." line becomes one vendor spec row. // The format carries no partnumbers, so rows stay unresolved and editable in the UI // until mapped through the active partnumber book. func parseTextBOM(data []byte, sourceFileName string) (*importedWorkspace, error) { lines := strings.Split(string(data), "\n") rows := make([]localdb.VendorSpecItem, 0, len(lines)) sortOrder := 10 serverModel := "" for _, raw := range lines { line := strings.TrimSpace(raw) if line == "" { continue } if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil { serverModel = strings.TrimSpace(m[1]) continue } m := textBOMItemLine.FindStringSubmatch(line) if m == nil { continue } description := strings.TrimSpace(m[1]) qty, err := strconv.Atoi(m[2]) if err != nil || qty <= 0 || description == "" { continue } rows = append(rows, localdb.VendorSpecItem{ SortOrder: sortOrder, VendorPartnumber: description, Quantity: qty, Description: description, }) sortOrder += 10 } if len(rows) == 0 { return nil, fmt.Errorf("text BOM has no importable rows") } name := serverModel if name == "" { name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName)) } if name == "" { name = "Text BOM Import" } return &importedWorkspace{ SourceFormat: "Text", SourceFileName: sourceFileName, Configurations: []importedConfiguration{ { GroupID: "text-0", Name: name, Line: 10, ServerCount: 1, ServerModel: serverModel, Rows: rows, }, }, }, nil } // IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export. // The file starts (after optional UTF-8 BOM) with the header line: // "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..." func IsQuoteForgeCSV(data []byte) bool { trimmed := bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) firstLine := trimmed if idx := bytes.IndexByte(trimmed, '\n'); idx >= 0 { firstLine = trimmed[:idx] } return bytes.HasPrefix(bytes.TrimSpace(firstLine), []byte("Line;Type;p/n;")) } // parseQuoteForgeCSV parses a QuoteForge own CSV export back into importable configurations. // Each server block (row where Line column is non-empty) becomes one importedConfiguration // with DirectItems populated from the component rows that follow it. func parseQuoteForgeCSV(data []byte, sourceFileName string) (*importedWorkspace, error) { data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) r := csv.NewReader(bytes.NewReader(data)) r.Comma = ';' r.FieldsPerRecord = -1 r.LazyQuotes = true records, err := r.ReadAll() if err != nil { return nil, fmt.Errorf("parse QuoteForge CSV: %w", err) } if len(records) == 0 { return nil, fmt.Errorf("QuoteForge CSV is empty") } // Skip header row (first row whose first cell is "Line") startIdx := 0 if len(records[0]) > 0 && strings.EqualFold(strings.TrimSpace(records[0][0]), "line") { startIdx = 1 } var configs []importedConfiguration var current *importedConfiguration blockIdx := 0 for _, record := range records[startIdx:] { if csvAllEmpty(record) { continue } lineCol := strings.TrimSpace(csvCol(record, 0)) pn := strings.TrimSpace(csvCol(record, 2)) if lineCol != "" { // New server block if current != nil { configs = append(configs, *current) } blockIdx++ serverCount := maxInt(parseInt(strings.TrimSpace(csvCol(record, 5))), 1) article := pn name := article if name == "" { name = fmt.Sprintf("Config %d", blockIdx) } current = &importedConfiguration{ GroupID: fmt.Sprintf("qfcsv-%d", blockIdx), Name: name, Line: blockIdx * 10, ServerCount: serverCount, Article: article, DirectItems: make(localdb.LocalConfigItems, 0), } } else if pn != "" && current != nil { // Component row qty := maxInt(parseInt(strings.TrimSpace(csvCol(record, 4))), 1) unitPrice := parseCSVPrice(strings.TrimSpace(csvCol(record, 6))) current.DirectItems = append(current.DirectItems, localdb.LocalConfigItem{ LotName: pn, Quantity: qty, UnitPrice: unitPrice, }) } } if current != nil { configs = append(configs, *current) } if len(configs) == 0 { return nil, fmt.Errorf("QuoteForge CSV has no importable configurations") } return &importedWorkspace{ SourceFormat: "QuoteForgeCSV", SourceFileName: sourceFileName, Configurations: configs, }, nil } // csvCol returns record[idx] or "" when idx is out of range. func csvCol(record []string, idx int) string { if idx < len(record) { return record[idx] } return "" } // csvAllEmpty reports whether every cell in the record is blank. func csvAllEmpty(record []string) bool { for _, cell := range record { if strings.TrimSpace(cell) != "" { return false } } return true } // parseCSVPrice parses a price string in QuoteForge CSV format: // comma as decimal separator, optional space as thousands separator. // Returns 0 on any parse failure. func parseCSVPrice(s string) float64 { if s == "" || s == "—" { return 0 } // Remove thousands separators (space, non-breaking space) s = strings.ReplaceAll(s, " ", "") s = strings.ReplaceAll(s, " ", "") s = strings.ReplaceAll(s, " ", "") // Replace comma decimal separator with dot s = strings.ReplaceAll(s, ",", ".") v, err := strconv.ParseFloat(s, 64) if err != nil { return 0 } return v }