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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user