From 24c34eb0e10bbe5dff206ad2ed861edb6f00abb0 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 16 Jun 2026 09:16:55 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20BOM=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B2=20=D0=BF=D0=B0=D1=81=D1=82=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D1=8B=D0=B9=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Паста BOM на странице конфигурирования теперь распознаёт текстовый и Inspur-форматы: вместо дублирования парсера на JS добавлен stateless эндпоинт POST /api/vendor-spec/parse-text, который использует те же детекторы и парсеры, что и импорт файла (KISS — один парсер на оба входа). JS-копии _parseInspurBOMText/_isInspurBOMText удалены. Заголовок конфигурации определяется по маркеру ", в составе:" с любым префиксом ("Сервер X3" и "Вычислительный GPU сервер X3" → модель X3); строки тримятся, пробел в начале не попадает в P/N; запятые и дефисы внутри описания сохраняются (RAID0,1,10; 8-GPU-2304GB). Co-Authored-By: Claude Opus 4.8 --- bible-local/09-vendor-spec.md | 23 +++- cmd/qfs/main.go | 3 + internal/handlers/vendor_spec.go | 22 ++++ internal/services/vendor_workspace_import.go | 35 +++++- .../services/vendor_workspace_import_test.go | 112 ++++++++++++++++++ web/templates/index.html | 98 ++++++++------- 6 files changed, 235 insertions(+), 58 deletions(-) diff --git a/bible-local/09-vendor-spec.md b/bible-local/09-vendor-spec.md index e7f64c4..8397dc6 100644 --- a/bible-local/09-vendor-spec.md +++ b/bible-local/09-vendor-spec.md @@ -85,14 +85,14 @@ Rules: 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 +Format: an optional header line ending with `, в составе:` 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. +so hyphens, commas, and digits inside the description (e.g. `8-GPU-2304GB`, `RAID0,1,10`) are preserved. Example: ``` -Сервер KR9288X3, в составе: +Вычислительный GPU сервер G5500V7, в составе: 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 шт. @@ -101,10 +101,25 @@ 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`; +- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the + last whitespace-separated token before the comma (so both `Сервер X3` and `Вычислительный GPU сервер X3` + resolve to `X3`); - without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty; +- each line is trimmed, so leading/trailing whitespace never enters `vendor_partnumber`; - 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. + +## Pasted BOM text parsing + +`POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM +(Inspur and Russian text BOM) into rows. Request body: `{"text": "..."}`. Response: +`{"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. diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 3446b6b..b5613aa 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -951,6 +951,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect pnBooks.GET("/:id", partnumberBooksHandler.GetItems) } + // Stateless BOM text parsing shared by paste and file-import paths. + api.POST("/vendor-spec/parse-text", vendorSpecHandler.ParseText) + // Configurations (public - RBAC disabled) configs := api.Group("/configs") { diff --git a/internal/handlers/vendor_spec.go b/internal/handlers/vendor_spec.go index 1feb1b9..53bb8b9 100644 --- a/internal/handlers/vendor_spec.go +++ b/internal/handlers/vendor_spec.go @@ -37,6 +37,28 @@ func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfigurati return cfg, nil } +// ParseText parses a pasted single-column text BOM (Inspur or Russian text BOM) +// using the same parsers as the vendor file-import path. It is stateless: no +// configuration is required. Returns the parsed rows and the detected format, or +// an empty result when the text is not a recognized single-column format (the +// client then falls back to manual column mapping). +// POST /api/vendor-spec/parse-text +func (h *VendorSpecHandler) ParseText(c *gin.Context) { + var body struct { + Text string `json:"text"` + } + if err := c.ShouldBindJSON(&body); err != nil { + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) + return + } + + rows, format := services.ParsePastedBOMText(body.Text) + if rows == nil { + rows = []localdb.VendorSpecItem{} + } + c.JSON(http.StatusOK, gin.H{"rows": rows, "format": format}) +} + // GetVendorSpec returns the vendor spec (BOM) for a configuration. // GET /api/configs/:uuid/vendor-spec func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) { diff --git a/internal/services/vendor_workspace_import.go b/internal/services/vendor_workspace_import.go index eb070aa..bcc61bc 100644 --- a/internal/services/vendor_workspace_import.go +++ b/internal/services/vendor_workspace_import.go @@ -690,8 +690,35 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err // 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+составе`) +// textBOMHeaderLine matches a configuration header ending with ", в составе:" +// regardless of the leading words (e.g. "Сервер " or +// "Вычислительный GPU сервер "). The captured group is everything before +// the comma; the model is its last whitespace-separated token. +var textBOMHeaderLine = regexp.MustCompile(`(?i)^(.*?)\s*,\s*в\s+составе`) + +// ParsePastedBOMText detects and parses a single-column text BOM (Inspur or +// Russian text BOM) pasted into the configurator. It shares the same detectors +// and parsers as the vendor file-import path, so paste and upload behave +// identically. It returns the parsed vendor spec rows and the detected format, +// or (nil, "") when the text is not a recognized single-column format and the +// caller should fall back to manual column mapping. +func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) { + data := []byte(text) + var ws *importedWorkspace + var err error + switch { + case IsInspurBOM(data): + ws, err = parseInspurBOM(data, "") + case IsTextBOM(data): + ws, err = parseTextBOM(data, "") + default: + return nil, "" + } + if err != nil || ws == nil || len(ws.Configurations) == 0 { + return nil, "" + } + return ws.Configurations[0].Rows, ws.SourceFormat +} // IsTextBOM reports whether data looks like a human-readable Russian text BOM, // i.e. it contains at least one " - шт." line. @@ -721,7 +748,9 @@ func parseTextBOM(data []byte, sourceFileName string) (*importedWorkspace, error continue } if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil { - serverModel = strings.TrimSpace(m[1]) + if fields := strings.Fields(m[1]); len(fields) > 0 { + serverModel = fields[len(fields)-1] + } continue } m := textBOMItemLine.FindStringSubmatch(line) diff --git a/internal/services/vendor_workspace_import_test.go b/internal/services/vendor_workspace_import_test.go index 95199df..6aa8d7d 100644 --- a/internal/services/vendor_workspace_import_test.go +++ b/internal/services/vendor_workspace_import_test.go @@ -2,6 +2,7 @@ package services import ( "path/filepath" + "strings" "testing" "time" @@ -689,6 +690,117 @@ NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up t } } +func TestParseTextBOMVariantHeaderAndLeadingSpace(t *testing.T) { + // Header does not start with "Сервер"; some lines have leading/trailing spaces; + // descriptions contain commas and internal hyphens. + const sample = `Вычислительный GPU сервер G5500V7, в составе: +Серверное шасси G5500 V7 (12NVMe + 8SAS/SATA) - 1 шт. +Процессор Intel 8558P 48C 2.7G 260MB 350W - 2 шт. +Модуль оперативной памяти Mem 128G DDR5-5600MHz ECC-RDIMM - 16 шт. +Накопитель SSD 2.5" NVMe 3.84TB - 8 шт. + Накопитель SSD 2.5" SATA 3.84TB - 2 шт. +Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache - 1 шт. +Адаптер 25GE(CX6-Lx)-Dual Port SFP28 - 1 шт. +Сетевая карта 4 x 1G, Base-T - 1 шт. +Адаптер HBA Emulex LPe32002 2 Port 32GFC - 1 шт. +Крепежный комплект Ball Bearing Rail Kit - 1 шт. +Кабельный органайзер Cable Management Arm - 1 шт. +Кабель питания PowerCord 3m C20 C19 - 4 шт. +Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7 - 8 шт. +Блок питания 3000W Titanium AC Power Supply - 4 шт.` + + workspace, err := parseTextBOM([]byte(sample), "spec.txt") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + cfg := workspace.Configurations[0] + if cfg.ServerModel != "G5500V7" { + t.Fatalf("expected ServerModel G5500V7 (last token before comma), got %q", cfg.ServerModel) + } + if cfg.Name != "G5500V7" { + t.Fatalf("expected name G5500V7, got %q", cfg.Name) + } + const wantRows = 14 + if len(cfg.Rows) != wantRows { + t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows)) + } + + for _, r := range cfg.Rows { + if r.VendorPartnumber != strings.TrimSpace(r.VendorPartnumber) { + t.Fatalf("vendor_partnumber has surrounding whitespace: %q", r.VendorPartnumber) + } + if r.Description != strings.TrimSpace(r.Description) { + t.Fatalf("description has surrounding whitespace: %q", r.Description) + } + if r.VendorPartnumber == "" { + t.Fatal("empty vendor_partnumber") + } + } + + rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows)) + for _, r := range cfg.Rows { + rowsByDesc[r.VendorPartnumber] = r + } + + // Leading-space line must yield a trimmed P/N. + sata, ok := rowsByDesc[`Накопитель SSD 2.5" SATA 3.84TB`] + if !ok { + t.Fatal("expected SATA SSD row not found (check leading-space trimming)") + } + if sata.Quantity != 2 { + t.Fatalf("SATA SSD: expected qty 2, got %d", sata.Quantity) + } + + // Commas inside the description must not break parsing. + raid, ok := rowsByDesc["Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache"] + if !ok { + t.Fatal("expected RAID adapter row not found (check commas in description)") + } + if raid.Quantity != 1 { + t.Fatalf("RAID adapter: expected qty 1, got %d", raid.Quantity) + } + + gpu, ok := rowsByDesc["Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7"] + if !ok { + t.Fatal("expected GPU row not found") + } + if gpu.Quantity != 8 { + t.Fatalf("GPU: expected qty 8, got %d", gpu.Quantity) + } +} + +func TestParsePastedBOMText(t *testing.T) { + t.Run("text BOM", func(t *testing.T) { + rows, format := ParsePastedBOMText("Сервер X1, в составе:\nCPU Intel 6760P - 2 шт.\nMem 128G - 16 шт.") + if format != "Text" { + t.Fatalf("expected format Text, got %q", format) + } + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + if rows[0].VendorPartnumber != "CPU Intel 6760P" || rows[0].Quantity != 2 { + t.Fatalf("unexpected first row: %+v", rows[0]) + } + }) + + t.Run("inspur BOM", func(t *testing.T) { + rows, format := ParsePastedBOMText("|CPU_AMD*1\n|PSU*2") + if format != "Inspur" { + t.Fatalf("expected format Inspur, got %q", format) + } + if len(rows) != 2 || rows[1].Quantity != 2 { + t.Fatalf("unexpected rows: %+v", rows) + } + }) + + t.Run("unrecognized falls through", func(t *testing.T) { + rows, format := ParsePastedBOMText("col a\tcol b\nfoo\tbar") + if rows != nil || format != "" { + t.Fatalf("expected nil/empty for unrecognized text, got rows=%+v format=%q", rows, format) + } + }) +} + func TestParseTextBOMNameFromFilename(t *testing.T) { const sample = "CPU Intel 6760P - 2 шт.\nMem 128G - 16 шт." workspace, err := parseTextBOM([]byte(sample), "my-config.txt") diff --git a/web/templates/index.html b/web/templates/index.html index baa10a2..69d4e0e 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -3054,62 +3054,44 @@ function _normalizeBomRawRows(rows) { }); } -function _parseInspurBOMText(text) { - const lines = text.split(/\r?\n/); - const result = []; - for (const raw of lines) { - const line = raw.trim(); - if (!line) continue; - const clean = line.startsWith('|') ? line.slice(1).trim() : line; - if (!clean) continue; - const starIdx = clean.lastIndexOf('*'); - if (starIdx > 0) { - const suffix = clean.slice(starIdx + 1).trim(); - if (/^\d+$/.test(suffix)) { - result.push([clean.slice(0, starIdx).trim(), suffix]); - continue; - } - } - result.push([clean, '1']); - } - return result; +function _applyParsedBOMRows(parsed) { + if (!Array.isArray(parsed) || !parsed.length) return false; + bomImportRaw = { + mode: 'raw', + rows: parsed, + columnTypes: ['pn', 'qty'], + ignoredRows: {}, + rowErrors: {}, + uiError: '' + }; + bomRows = []; + _setBomUIError(''); + rebuildBOMRowsFromRaw(); + renderBOMTable(); + return true; } -function _isInspurBOMText(text) { - const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0); - if (!lines.length) return false; - let matches = 0; - for (const line of lines) { - const t = line.trim(); - const idx = t.lastIndexOf('*'); - if (idx > 0 && /^\d+$/.test(t.slice(idx + 1).trim())) matches++; +// Detection and parsing of known single-column text BOM formats (Inspur, Russian +// text BOM) lives only on the server (internal/services/vendor_workspace_import.go), +// shared with the vendor file-import path. The paste handler asks the server to +// parse; an unrecognized payload falls back to the generic Excel column grid below. +async function _serverParseBOMText(text) { + try { + const resp = await fetch('/api/vendor-spec/parse-text', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({text}) + }); + if (!resp.ok) return null; + const data = await resp.json(); + if (!Array.isArray(data.rows) || !data.rows.length) return null; + return data.rows.map(r => [r.vendor_partnumber || '', String(r.quantity || '')]); + } catch (e) { + return null; } - return matches > 0 && matches >= Math.ceil(lines.length * 0.5); } -function handleBOMPaste(event) { - event.preventDefault(); - const text = event.clipboardData.getData('text/plain'); - if (!text || !text.trim()) return; - - if (_isInspurBOMText(text)) { - const parsed = _parseInspurBOMText(text); - if (!parsed.length) return; - bomImportRaw = { - mode: 'raw', - rows: parsed, - columnTypes: ['pn', 'qty'], - ignoredRows: {}, - rowErrors: {}, - uiError: '' - }; - bomRows = []; - _setBomUIError(''); - rebuildBOMRowsFromRaw(); - renderBOMTable(); - return; - } - +function _applyGenericBOMPaste(text) { const lines = text.split(/\r?\n/).filter(l => l.length > 0); if (!lines.length) return; const rows = lines.map(l => l.split('\t').map(c => c.trim())); @@ -3129,6 +3111,20 @@ function handleBOMPaste(event) { renderBOMTable(); } +async function handleBOMPaste(event) { + event.preventDefault(); + const text = event.clipboardData.getData('text/plain'); + if (!text || !text.trim()) return; + + // Tabs mean a real spreadsheet table — go straight to the column grid. + if (!text.includes('\t')) { + const parsed = await _serverParseBOMText(text); + if (_applyParsedBOMRows(parsed)) return; + } + + _applyGenericBOMPaste(text); +} + function _getBomColumnTypeIndexes() { if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null; const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };