From b6fdac1caad6960eef5b96687b0e2709d18844cf Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Wed, 17 Jun 2026 07:42:46 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Nx=20BOM=20import=20=E2=80=94=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20x=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен новый вариант импорта спеки: quantity-first формат, где каждая строка начинается с `x ` (например, «2x Intel Xeon 8570»). Порядок детекции: Inspur → Nx → Text BOM. Заголовок «, в составе:» работает так же, как в Text BOM — последний токен перед запятой становится server_model. Co-Authored-By: Claude Sonnet 4.6 --- bible-local/09-vendor-spec.md | 43 ++++++++- internal/services/vendor_workspace_import.go | 91 ++++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/bible-local/09-vendor-spec.md b/bible-local/09-vendor-spec.md index 8397dc6..6fce2d3 100644 --- a/bible-local/09-vendor-spec.md +++ b/bible-local/09-vendor-spec.md @@ -112,6 +112,41 @@ Rules: - lines that do not match ` - шт.` are skipped; - no price data is present in the format; `unit_price` and `total_price` are left nil. +## Nx BOM import (quantity-first) + +The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a quantity-first BOM +where each item line begins with `x `. + +Format: an optional header line ending with `, в составе:` followed by one component per line as +`x `. The `x` separator is case-insensitive; parentheses, commas, and hyphens +inside the description are preserved as-is. + +Example: +``` +Сервер G893-SD1-AAX3, в составе: +1x 8U 2CPU 8GPU Server System (32x DDR5 DIMM Slots,8x 2.5" Hot-Swap Drive Bays, 4+4 3000W, 2x 10Gb/s RJ45, 2x IPMI RJ45) +2x Intel Xeon 8570 (56 cores, 2.1GHz, 300MB, 350W) +32x 64GB DDR5 ECC RDIMM +1x GPU Nvidia HGX H200 141GB 8GPU +3x 1.92TB NVMe PCIe SFF RI +5x 7.68TB NVMe PCIe SFF RI +8x 1-port 400G NDR OSFP CX7 +2x 2-port 100GbE QSFP56 CX6 +1x 2-port 10GbE RJ45 +``` + +Rules: +- the entire file becomes a single configuration (`server_count = 1`); +- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the + last whitespace-separated token before the comma; +- 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 `x ` are skipped; +- no price data is present in the format; `unit_price` and `total_price` are left nil; +- detection runs before Text BOM in the format switch (Inspur → Nx → Text). + ## Pasted BOM text parsing `POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM @@ -119,7 +154,7 @@ Rules: `{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`. This shares the exact detectors and parsers used by the file-import path -(`ParsePastedBOMText` → `IsInspurBOM`/`parseInspurBOM`, `IsTextBOM`/`parseTextBOM`), so paste and upload -behave identically — there is no second parser in the frontend. The configurator's BOM paste box calls -this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real spreadsheet table) -falls back to the manual column-mapping grid. +(`ParsePastedBOMText` → `IsInspurBOM`/`parseInspurBOM`, `IsNxBOM`/`parseNxBOM`, `IsTextBOM`/`parseTextBOM`), +so paste and upload behave identically — there is no second parser in the frontend. The configurator's BOM +paste box calls this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real +spreadsheet table) falls back to the manual column-mapping grid. diff --git a/internal/services/vendor_workspace_import.go b/internal/services/vendor_workspace_import.go index bcc61bc..81daaf8 100644 --- a/internal/services/vendor_workspace_import.go +++ b/internal/services/vendor_workspace_import.go @@ -135,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 IsNxBOM(data): + workspace, err = parseNxBOM(data, filepath.Base(sourceFileName)) case IsTextBOM(data): workspace, err = parseTextBOM(data, filepath.Base(sourceFileName)) default: @@ -683,6 +685,93 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err }, nil } +// nxBOMItemLine matches a quantity-first BOM line: "x " +// where the quantity prefix is digits followed immediately by "x" (case-insensitive). +// Parentheses, commas, and hyphens inside the description are preserved. +var nxBOMItemLine = regexp.MustCompile(`(?i)^(\d+)[xX]\s+(.+\S)\s*$`) + +// IsNxBOM reports whether data looks like a quantity-first "Nx" BOM where each +// item line begins with "x " (e.g. "2x Intel Xeon 8570 ..."). +func IsNxBOM(data []byte) bool { + for _, raw := range strings.Split(string(data), "\n") { + if nxBOMItemLine.MatchString(strings.TrimSpace(raw)) { + return true + } + } + return false +} + +// parseNxBOM parses a quantity-first "Nx" BOM into a single configuration. +// An optional header line ending with ", в составе:" supplies server_model and name. +// Each "x " line becomes one vendor spec row; 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. +func parseNxBOM(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 { + if fields := strings.Fields(m[1]); len(fields) > 0 { + serverModel = fields[len(fields)-1] + } + continue + } + m := nxBOMItemLine.FindStringSubmatch(line) + if m == nil { + continue + } + qty, err := strconv.Atoi(m[1]) + if err != nil || qty <= 0 { + continue + } + description := strings.TrimSpace(m[2]) + if 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("Nx BOM has no importable rows") + } + + name := serverModel + if name == "" { + name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName)) + } + if name == "" { + name = "Nx BOM Import" + } + + return &importedWorkspace{ + SourceFormat: "Nx", + SourceFileName: sourceFileName, + Configurations: []importedConfiguration{ + { + GroupID: "nx-0", + Name: name, + Line: 10, + ServerCount: 1, + ServerModel: serverModel, + 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 "шт". @@ -709,6 +798,8 @@ func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) { switch { case IsInspurBOM(data): ws, err = parseInspurBOM(data, "") + case IsNxBOM(data): + ws, err = parseNxBOM(data, "") case IsTextBOM(data): ws, err = parseTextBOM(data, "") default: