diff --git a/internal/services/vendor_workspace_import.go b/internal/services/vendor_workspace_import.go
index 8d555f9..324fc46 100644
--- a/internal/services/vendor_workspace_import.go
+++ b/internal/services/vendor_workspace_import.go
@@ -2,6 +2,7 @@ package services
import (
"bytes"
+ "encoding/csv"
"encoding/xml"
"fmt"
"path/filepath"
@@ -47,7 +48,8 @@ type importedConfiguration struct {
ServerModel string
Article string
CurrencyCode string
- Rows []localdb.VendorSpecItem
+ Rows []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur)
+ DirectItems localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV)
TotalPrice *float64
}
@@ -128,6 +130,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
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))
default:
@@ -148,10 +152,28 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
for _, imported := range workspace.Configurations {
now := time.Now()
cfgUUID := uuid.NewString()
- groupRows, items, totalPrice, estimatePricelistID, err := s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
- if err != nil {
- return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, err)
+
+ 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,
@@ -653,3 +675,135 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
},
}, 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
+}
diff --git a/internal/services/vendor_workspace_import_test.go b/internal/services/vendor_workspace_import_test.go
index d8e8a79..b047b71 100644
--- a/internal/services/vendor_workspace_import_test.go
+++ b/internal/services/vendor_workspace_import_test.go
@@ -463,6 +463,112 @@ PowerSupply_1300W*2`
}
}
+func TestParseQuoteForgeCSV(t *testing.T) {
+ // Format mirrors ToCSV output: col[0]=Line, col[1]=Type, col[2]=p/n,
+ // col[3]=Description, col[4]=Qty(1pcs), col[5]=Qty(total), col[6]=Price(1pcs), col[7]=Price(total)
+ const sample = "\xEF\xBB\xBF" + // UTF-8 BOM
+ "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n" +
+ "10;;DL380-ARTICLE;;;2;10470;20 940\n" +
+ ";MEMORY;MB_INTEL_A1;;1;;2074,5;\n" +
+ ";CPU;CPU_XEON_X;;2;;5100;\n" +
+ "\n" +
+ "20;;DL380-ARTICLE-2;;;1;8000;8 000\n" +
+ ";STORAGE;SSD_NVMe;;4;;1200;\n"
+
+ workspace, err := parseQuoteForgeCSV([]byte(sample), "project.csv")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if workspace.SourceFormat != "QuoteForgeCSV" {
+ t.Fatalf("expected SourceFormat QuoteForgeCSV, got %q", workspace.SourceFormat)
+ }
+ if len(workspace.Configurations) != 2 {
+ t.Fatalf("expected 2 configurations, got %d", len(workspace.Configurations))
+ }
+
+ cfg1 := workspace.Configurations[0]
+ if cfg1.Article != "DL380-ARTICLE" {
+ t.Fatalf("cfg1 article: want DL380-ARTICLE, got %q", cfg1.Article)
+ }
+ if cfg1.ServerCount != 2 {
+ t.Fatalf("cfg1 server_count: want 2, got %d", cfg1.ServerCount)
+ }
+ if len(cfg1.DirectItems) != 2 {
+ t.Fatalf("cfg1 items: want 2, got %d", len(cfg1.DirectItems))
+ }
+ if cfg1.DirectItems[0].LotName != "MB_INTEL_A1" || cfg1.DirectItems[0].Quantity != 1 {
+ t.Fatalf("cfg1 item[0]: %+v", cfg1.DirectItems[0])
+ }
+ if cfg1.DirectItems[1].LotName != "CPU_XEON_X" || cfg1.DirectItems[1].Quantity != 2 {
+ t.Fatalf("cfg1 item[1]: %+v", cfg1.DirectItems[1])
+ }
+ if cfg1.DirectItems[1].UnitPrice != 5100 {
+ t.Fatalf("cfg1 item[1] price: want 5100, got %v", cfg1.DirectItems[1].UnitPrice)
+ }
+
+ cfg2 := workspace.Configurations[1]
+ if cfg2.Article != "DL380-ARTICLE-2" {
+ t.Fatalf("cfg2 article: want DL380-ARTICLE-2, got %q", cfg2.Article)
+ }
+ if cfg2.ServerCount != 1 {
+ t.Fatalf("cfg2 server_count: want 1, got %d", cfg2.ServerCount)
+ }
+ if len(cfg2.DirectItems) != 1 || cfg2.DirectItems[0].LotName != "SSD_NVMe" {
+ t.Fatalf("cfg2 items: %+v", cfg2.DirectItems)
+ }
+}
+
+func TestIsQuoteForgeCSV(t *testing.T) {
+ withBOM := "\xEF\xBB\xBFLine;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n10;;ART;;1;;100;\n"
+ noBOM := "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n"
+
+ cases := []struct {
+ input string
+ want bool
+ }{
+ {withBOM, true},
+ {noBOM, true},
+ {"