From 7233a0780fd6ebcd9bda66ad49028e6c8c1368a2 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 16 Jun 2026 09:06:26 +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=87=D0=B5=D0=BB=D0=BE=D0=B2=D0=B5=D0=BA=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=D0=B5=D0=BC=D0=BE=D0=B3=D0=BE=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20BOM=20(=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20"<=D0=BE=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5>=20-=20N=20=D1=88=D1=82.")?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый формат vendor-import: опциональный заголовок "Сервер <модель>, в составе:" и строки вида "<описание> - <кол-во> шт." (дефис/тире, пробел перед "шт" и точка опциональны). Количество якорится в конце строки, поэтому дефисы и цифры внутри описания (8-GPU-2304GB) сохраняются. Описание пишется и в vendor_partnumber, и в description: строки резолвятся через активную книгу партномеров, иначе остаются нерезолвленными и редактируемыми. Весь файл — одна конфигурация. Co-Authored-By: Claude Opus 4.8 --- bible-local/09-vendor-spec.md | 28 +++++ cmd/qfs/main.go | 2 +- internal/services/vendor_workspace_import.go | 90 +++++++++++++ .../services/vendor_workspace_import_test.go | 118 ++++++++++++++++++ 4 files changed, 237 insertions(+), 1 deletion(-) diff --git a/bible-local/09-vendor-spec.md b/bible-local/09-vendor-spec.md index 68ec9de..e7f64c4 100644 --- a/bible-local/09-vendor-spec.md +++ b/bible-local/09-vendor-spec.md @@ -80,3 +80,31 @@ Rules: - configuration `name` is derived from the uploaded filename (without extension); - lines that do not contain `*` are skipped; - no price data is present in the format; `unit_price` and `total_price` are left nil. + +## Text BOM import + +The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a human-readable Russian text BOM. + +Format: an optional header line `Сервер , в составе:` followed by one component per line as +` - шт.`. The separator may be a hyphen, en-dash, or em-dash; the space before +`шт` is optional; a trailing `.` after `шт` is optional. Quantities are anchored to the end of the line, +so hyphens and digits inside the description (e.g. `8-GPU-2304GB`) are preserved. + +Example: +``` +Сервер KR9288X3, в составе: +GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт. +CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт. +Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт. +NVIDIA twin port transceiver, 800Gbps, OSFP - 8шт. +``` + +Rules: +- the entire file becomes a single configuration (`server_count = 1`); +- the `Сервер , в составе:` header supplies the configuration `name` and `server_model`; +- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty; +- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and + `description`, so rows resolve through the active partnumber book when matched and otherwise stay + unresolved and editable in the UI; +- lines that do not match ` - шт.` are skipped; +- no price data is present in the format; `unit_price` and `total_price` are left nil. diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index eefa84d..3446b6b 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1725,7 +1725,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge) return } - if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) { + if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) && !services.IsTextBOM(data) { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"}) return } diff --git a/internal/services/vendor_workspace_import.go b/internal/services/vendor_workspace_import.go index e8a307d..eb070aa 100644 --- a/internal/services/vendor_workspace_import.go +++ b/internal/services/vendor_workspace_import.go @@ -6,6 +6,7 @@ import ( "encoding/xml" "fmt" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -134,6 +135,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s 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") } @@ -680,6 +683,93 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err }, 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);..." diff --git a/internal/services/vendor_workspace_import_test.go b/internal/services/vendor_workspace_import_test.go index b047b71..95199df 100644 --- a/internal/services/vendor_workspace_import_test.go +++ b/internal/services/vendor_workspace_import_test.go @@ -588,3 +588,121 @@ func TestIsInspurBOM(t *testing.T) { } } } + +func TestIsTextBOM(t *testing.T) { + cases := []struct { + input string + want bool + }{ + {"CPU Intel 6760P - 2 шт.", true}, + {"Fan 18Krpm 8086 - 20 шт.\nRail L-Type 665mm - 1 шт.", true}, + {"NVIDIA transceiver - 8шт.", true}, // no space before шт + {"Сервер KR9288X3, в составе:\nFan - 4 шт.", true}, + {"|CPU_AMD*1\n|PSU*2", false}, // Inspur + {"\n", false}, + {"just text\nno quantities", false}, + {"CPU - 2 pcs.", false}, // not Russian шт + {"", false}, + } + for _, tc := range cases { + got := IsTextBOM([]byte(tc.input)) + if got != tc.want { + t.Errorf("IsTextBOM(%q) = %v, want %v", tc.input, got, tc.want) + } + } +} + +func TestParseTextBOM(t *testing.T) { + const sample = `Сервер KR9288X3, в составе: +GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт. +incl. onboard 800G XDR - 8 шт. +CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт. +Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт. +SSD 960G U.2 16GTps 2.5in RAID_1 - 2 шт. +SSD 3.84T U.2 16GTps 2.5in R-Standard - 2 шт. +NIC 25Gbps 2Port LC Nvidia CX6LX PCIe MM GEN4 - 1 шт. +PowerSupply 3200W Titanium 220VACor240VDC - 2 шт. +PowerSupply 3300W Titanium 220VACor240VDC - 6 шт. +PowerCord 1.9M C20 C19 - 14 шт. +Rail L-Type 665mm - 1 шт. +Chassis 2.5x12 gpu - 1 шт. +Fan 18Krpm 8086 - 20 шт. +NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top - 8шт.` + + workspace, err := parseTextBOM([]byte(sample), "spec.txt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if workspace.SourceFormat != "Text" { + t.Fatalf("expected SourceFormat Text, got %q", workspace.SourceFormat) + } + if len(workspace.Configurations) != 1 { + t.Fatalf("expected 1 configuration, got %d", len(workspace.Configurations)) + } + cfg := workspace.Configurations[0] + if cfg.Name != "KR9288X3" { + t.Fatalf("expected name KR9288X3 (from header), got %q", cfg.Name) + } + if cfg.ServerModel != "KR9288X3" { + t.Fatalf("expected ServerModel KR9288X3, got %q", cfg.ServerModel) + } + if cfg.ServerCount != 1 { + t.Fatalf("expected ServerCount 1, got %d", cfg.ServerCount) + } + const wantRows = 14 + if len(cfg.Rows) != wantRows { + t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows)) + } + + rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows)) + for _, r := range cfg.Rows { + rowsByDesc[r.Description] = r + if r.VendorPartnumber != r.Description { + t.Fatalf("expected VendorPartnumber to mirror Description, got pn=%q desc=%q", r.VendorPartnumber, r.Description) + } + } + + // Description with internal hyphens and digits must not be split early. + gpu, ok := rowsByDesc["GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E"] + if !ok { + t.Fatal("expected GPU row not found (check hyphen handling)") + } + if gpu.Quantity != 1 { + t.Fatalf("GPU: expected qty 1, got %d", gpu.Quantity) + } + + mem, ok := rowsByDesc["Mem 128G DDR5-6400MHz ECC-RDIMM"] + if !ok { + t.Fatal("expected Mem row not found") + } + if mem.Quantity != 16 { + t.Fatalf("Mem: expected qty 16, got %d", mem.Quantity) + } + + // Quantity with no space before "шт" and commas/hyphens in description. + xcvr, ok := rowsByDesc["NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top"] + if !ok { + t.Fatal("expected transceiver row not found (check no-space quantity)") + } + if xcvr.Quantity != 8 { + t.Fatalf("transceiver: expected qty 8, got %d", xcvr.Quantity) + } +} + +func TestParseTextBOMNameFromFilename(t *testing.T) { + const sample = "CPU Intel 6760P - 2 шт.\nMem 128G - 16 шт." + workspace, err := parseTextBOM([]byte(sample), "my-config.txt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + cfg := workspace.Configurations[0] + if cfg.Name != "my-config" { + t.Fatalf("expected name my-config (from filename), got %q", cfg.Name) + } + if cfg.ServerModel != "" { + t.Fatalf("expected empty ServerModel without header, got %q", cfg.ServerModel) + } + if len(cfg.Rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(cfg.Rows)) + } +}