From b48436de93e31f9cd730a915538709e23e666e43 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 21 Jul 2026 13:20:01 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=BA=D0=B0=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B0?= =?UTF-8?q?=20CSV=20=D0=B8=D0=B7=20pricing-=D1=8D=D0=BA=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B0=20(customer-facing=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Позволяет заново импортировать проект из CSV, экспортированного кнопкой "Экспорт CSV" (Line Item;LOT;PN вендора;...), а не только из round-trip формата (Line;Type;p/n;...). Колонки резолвятся по имени, т.к. набор опциональных столбцов зависит от настроек экспорта. Co-Authored-By: Claude Sonnet 5 --- internal/services/vendor_workspace_import.go | 120 ++++++++++++++++++ .../services/vendor_workspace_import_test.go | 82 ++++++++++++ 2 files changed, 202 insertions(+) diff --git a/internal/services/vendor_workspace_import.go b/internal/services/vendor_workspace_import.go index 22be18b..af89103 100644 --- a/internal/services/vendor_workspace_import.go +++ b/internal/services/vendor_workspace_import.go @@ -133,6 +133,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName)) case IsQuoteForgeCSV(data): workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName)) + case IsQuoteForgePricingCSV(data): + workspace, err = parsePricingCSV(data, filepath.Base(sourceFileName)) case IsInspurBOM(data): workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName)) case IsNxBOM(data): @@ -984,6 +986,124 @@ func parseQuoteForgeCSV(data []byte, sourceFileName string) (*importedWorkspace, }, nil } +// IsQuoteForgePricingCSV reports whether data looks like a QuoteForge project +// pricing export (the customer-facing FOB/DDP CSV, distinct from the round-trip +// "Line;Type;p/n" format). It starts (after optional UTF-8 BOM) with a header +// whose first column is "Line Item". The optional columns (LOT, BOM, Estimate, +// Stock, Конкуренты) vary by export settings, so parsing resolves columns by name. +func IsQuoteForgePricingCSV(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 Item;")) +} + +// parsePricingCSV parses a QuoteForge project pricing export back into importable +// configurations. Unlike parseQuoteForgeCSV, component lot names live in the "LOT" +// column rather than "p/n" (which is empty/vendor part number in this format), so +// the LOT column is required to reconstruct the BOM. Prices come from "Estimate" +// (falling back to "BOM") since that column holds the price the export was based on. +func parsePricingCSV(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 pricing CSV: %w", err) + } + if len(records) == 0 { + return nil, fmt.Errorf("pricing CSV is empty") + } + + col := make(map[string]int, len(records[0])) + for i, name := range records[0] { + col[strings.ToLower(strings.TrimSpace(name))] = i + } + + lineIdx, ok := col["line item"] + if !ok { + return nil, fmt.Errorf("pricing CSV has no Line Item column") + } + lotIdx, hasLot := col["lot"] + if !hasLot { + return nil, fmt.Errorf("pricing CSV has no LOT column — cannot reconstruct components") + } + qtyIdx, hasQty := col["кол-во"] + if !hasQty { + return nil, fmt.Errorf("pricing CSV has no Кол-во column") + } + descIdx, hasDesc := col["описание"] + estimateIdx, hasEstimate := col["estimate"] + bomIdx, hasBOM := col["bom"] + + var configs []importedConfiguration + var current *importedConfiguration + blockIdx := 0 + + for _, record := range records[1:] { + if csvAllEmpty(record) { + continue + } + lineCol := strings.TrimSpace(csvCol(record, lineIdx)) + lot := strings.TrimSpace(csvCol(record, lotIdx)) + + if lineCol != "" { + if current != nil { + configs = append(configs, *current) + } + blockIdx++ + name := "" + if hasDesc { + name = strings.TrimSpace(csvCol(record, descIdx)) + } + if name == "" || name == "—" { + name = fmt.Sprintf("Config %d", blockIdx) + } + serverCount := maxInt(parseInt(strings.TrimSpace(csvCol(record, qtyIdx))), 1) + current = &importedConfiguration{ + GroupID: fmt.Sprintf("pricingcsv-%d", blockIdx), + Name: name, + Line: blockIdx * 10, + ServerCount: serverCount, + DirectItems: make(localdb.LocalConfigItems, 0), + } + } else if lot != "" && lot != "—" && current != nil { + qty := maxInt(parseInt(strings.TrimSpace(csvCol(record, qtyIdx))), 1) + unitPrice := 0.0 + if hasEstimate { + unitPrice = parseCSVPrice(strings.TrimSpace(csvCol(record, estimateIdx))) + } + if unitPrice == 0 && hasBOM { + unitPrice = parseCSVPrice(strings.TrimSpace(csvCol(record, bomIdx))) + } + current.DirectItems = append(current.DirectItems, localdb.LocalConfigItem{ + LotName: lot, + Quantity: qty, + UnitPrice: unitPrice, + }) + } + } + if current != nil { + configs = append(configs, *current) + } + + if len(configs) == 0 { + return nil, fmt.Errorf("pricing CSV has no importable configurations") + } + + return &importedWorkspace{ + SourceFormat: "PricingCSV", + 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) { diff --git a/internal/services/vendor_workspace_import_test.go b/internal/services/vendor_workspace_import_test.go index 6aa8d7d..6be5652 100644 --- a/internal/services/vendor_workspace_import_test.go +++ b/internal/services/vendor_workspace_import_test.go @@ -542,6 +542,88 @@ func TestIsQuoteForgeCSV(t *testing.T) { } } +func TestParsePricingCSV(t *testing.T) { + // Format mirrors pricingCSVHeaders/pricingCSVRow output: the customer-facing + // pricing export, distinct from ToCSV's "Line;Type;p/n" round-trip format. + const sample = "\xEF\xBB\xBF" + // UTF-8 BOM + "Line Item;LOT;PN вендора;Описание;Кол-во;BOM;Estimate;Stock;Конкуренты\n" + + "10;;;5_ООО+РН-Бурение_NOL100582279;3;—;18 562,00;3 590,18;17 983,85\n" + + ";MB_AMD_4.Genoa_2S;—;—;1;—;3 906,00;—;—\n" + + ";CPU_AMD_9224;—;—;2;—;2 000,00;—;2 266,66\n" + + "\n" + + "20;;;11_АО+Ангарскнефтехимпроект_NOL100574528;2;—;30 283,00;12 889,60;32 254,39\n" + + ";MEM_DDR5_64G_5600;—;—;8;—;17 200,00;8 032,56;23 384,32\n" + + workspace, err := parsePricingCSV([]byte(sample), "pricing.csv") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if workspace.SourceFormat != "PricingCSV" { + t.Fatalf("expected SourceFormat PricingCSV, 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.Name != "5_ООО+РН-Бурение_NOL100582279" { + t.Fatalf("cfg1 name: got %q", cfg1.Name) + } + if cfg1.ServerCount != 3 { + t.Fatalf("cfg1 server_count: want 3, 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_AMD_4.Genoa_2S" || cfg1.DirectItems[0].Quantity != 1 || cfg1.DirectItems[0].UnitPrice != 3906 { + t.Fatalf("cfg1 item[0]: %+v", cfg1.DirectItems[0]) + } + if cfg1.DirectItems[1].LotName != "CPU_AMD_9224" || cfg1.DirectItems[1].Quantity != 2 || cfg1.DirectItems[1].UnitPrice != 2000 { + t.Fatalf("cfg1 item[1]: %+v", cfg1.DirectItems[1]) + } + + cfg2 := workspace.Configurations[1] + if cfg2.Name != "11_АО+Ангарскнефтехимпроект_NOL100574528" { + t.Fatalf("cfg2 name: got %q", cfg2.Name) + } + if cfg2.ServerCount != 2 { + t.Fatalf("cfg2 server_count: want 2, got %d", cfg2.ServerCount) + } + if len(cfg2.DirectItems) != 1 || cfg2.DirectItems[0].LotName != "MEM_DDR5_64G_5600" { + t.Fatalf("cfg2 items: %+v", cfg2.DirectItems) + } +} + +func TestIsQuoteForgePricingCSV(t *testing.T) { + withBOM := "\xEF\xBB\xBFLine Item;LOT;PN вендора;Описание;Кол-во;BOM;Estimate;Stock;Конкуренты\n10;;;name;1;—;100;—;—\n" + noLOT := "Line Item;PN вендора;Описание;Кол-во;BOM;Estimate;Stock;Конкуренты\n10;;name;1;—;100;—;—\n" + + cases := []struct { + input string + want bool + }{ + {withBOM, true}, + {noLOT, true}, + {"Line;Type;p/n;Description\n", false}, + {"\n", false}, + {"", false}, + } + for _, tc := range cases { + got := IsQuoteForgePricingCSV([]byte(tc.input)) + if got != tc.want { + t.Errorf("IsQuoteForgePricingCSV(%q) = %v, want %v", tc.input[:min(len(tc.input), 40)], got, tc.want) + } + } +} + +func TestParsePricingCSV_NoLOTColumn(t *testing.T) { + const sample = "Line Item;PN вендора;Описание;Кол-во;BOM;Estimate;Stock;Конкуренты\n" + + "10;;name;1;—;100;—;—\n" + if _, err := parsePricingCSV([]byte(sample), "pricing.csv"); err == nil { + t.Fatal("expected error when LOT column is missing") + } +} + func TestParseCSVPrice(t *testing.T) { cases := []struct { input string