From 5d4e1b44f654998752ec6cb89fce241b062c7d72 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sun, 24 May 2026 18:54:08 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B1=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20CSV=20QuoteForge=20+=20fix=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=86=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен парсер собственного CSV-экспорта QuoteForge (IsQuoteForgeCSV / parseQuoteForgeCSV). Формат: UTF-8 BOM + заголовок Line;Type;p/n;..., блоки сервер → компоненты. DirectItems создаются напрямую без прохода через VendorSpecResolver. Модальное окно импорта принимает .csv/.txt/.xml. Fix кнопки «Обновить цены» на странице варианта: после синхронизации прайс-листов запрашивается актуальный estimate-прайслист и передаётся явным pricelist_id в каждый POST /api/configs/:uuid/refresh-prices. Ранее использовался устаревший ID, сохранённый в конфигурации. Co-Authored-By: Claude Sonnet 4.6 --- internal/services/vendor_workspace_import.go | 162 +++++++++++++++++- .../services/vendor_workspace_import_test.go | 106 ++++++++++++ web/templates/project_detail.html | 31 +++- 3 files changed, 289 insertions(+), 10 deletions(-) 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}, + {"\n", false}, + {"|CPU*1\n|PSU*2", false}, + {"", false}, + {"Line;other;columns\n", false}, + } + for _, tc := range cases { + got := IsQuoteForgeCSV([]byte(tc.input)) + if got != tc.want { + t.Errorf("IsQuoteForgeCSV(%q) = %v, want %v", tc.input[:min(len(tc.input), 40)], got, tc.want) + } + } +} + +func TestParseCSVPrice(t *testing.T) { + cases := []struct { + input string + want float64 + }{ + {"2074,5", 2074.5}, + {"5100", 5100}, + {"104 700", 104700}, + {"20 940", 20940}, + {"—", 0}, + {"", 0}, + {"abc", 0}, + } + for _, tc := range cases { + got := parseCSVPrice(tc.input) + if got != tc.want { + t.Errorf("parseCSVPrice(%q) = %v, want %v", tc.input, got, tc.want) + } + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + func TestIsInspurBOM(t *testing.T) { cases := []struct { input string diff --git a/web/templates/project_detail.html b/web/templates/project_detail.html index 3a5801a..6cdb86c 100644 --- a/web/templates/project_detail.html +++ b/web/templates/project_detail.html @@ -108,14 +108,14 @@