fix: текстовый BOM работает в пасте конфигуратора через единый серверный парсер

Паста 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 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-06-16 09:16:55 +03:00
parent 6f2c261350
commit 24c34eb0e1
6 changed files with 235 additions and 58 deletions

View File

@@ -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) {

View File

@@ -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 "Сервер <model>, в составе:".
var textBOMHeaderLine = regexp.MustCompile(`(?i)^\s*сервер\s+(.+?)\s*,\s*в\s+составе`)
// textBOMHeaderLine matches a configuration header ending with ", в составе:"
// regardless of the leading words (e.g. "Сервер <model>" or
// "Вычислительный GPU сервер <model>"). 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 "<description> - <quantity> шт." 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)

View File

@@ -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")