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

@@ -85,14 +85,14 @@ Rules:
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a human-readable Russian text BOM. The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a human-readable Russian text BOM.
Format: an optional header line `Сервер <model>, в составе:` followed by one component per line as Format: an optional header line ending with `, в составе:` followed by one component per line as
`<description> - <quantity> шт.`. The separator may be a hyphen, en-dash, or em-dash; the space before `<description> - <quantity> шт.`. 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, `шт` 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: Example:
``` ```
Сервер KR9288X3, в составе: Вычислительный GPU сервер G5500V7, в составе:
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт. GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт. CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт. Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
@@ -101,10 +101,25 @@ NVIDIA twin port transceiver, 800Gbps, OSFP - 8шт.
Rules: Rules:
- the entire file becomes a single configuration (`server_count = 1`); - the entire file becomes a single configuration (`server_count = 1`);
- the `Сервер <model>, в составе:` 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; - 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 - 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 `description`, so rows resolve through the active partnumber book when matched and otherwise stay
unresolved and editable in the UI; unresolved and editable in the UI;
- lines that do not match `<description> - <quantity> шт.` are skipped; - lines that do not match `<description> - <quantity> шт.` are skipped;
- no price data is present in the format; `unit_price` and `total_price` are left nil. - 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.

View File

@@ -951,6 +951,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pnBooks.GET("/:id", partnumberBooksHandler.GetItems) 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) // Configurations (public - RBAC disabled)
configs := api.Group("/configs") configs := api.Group("/configs")
{ {

View File

@@ -37,6 +37,28 @@ func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfigurati
return cfg, nil 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. // GetVendorSpec returns the vendor spec (BOM) for a configuration.
// GET /api/configs/:uuid/vendor-spec // GET /api/configs/:uuid/vendor-spec
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) { 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. // description (e.g. "8-GPU-2304GB") from being mistaken for the separator.
var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`) var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`)
// textBOMHeaderLine matches the configuration header "Сервер <model>, в составе:". // textBOMHeaderLine matches a configuration header ending with ", в составе:"
var textBOMHeaderLine = regexp.MustCompile(`(?i)^\s*сервер\s+(.+?)\s*,\s*в\s+составе`) // 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, // IsTextBOM reports whether data looks like a human-readable Russian text BOM,
// i.e. it contains at least one "<description> - <quantity> шт." line. // i.e. it contains at least one "<description> - <quantity> шт." line.
@@ -721,7 +748,9 @@ func parseTextBOM(data []byte, sourceFileName string) (*importedWorkspace, error
continue continue
} }
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil { 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 continue
} }
m := textBOMItemLine.FindStringSubmatch(line) m := textBOMItemLine.FindStringSubmatch(line)

View File

@@ -2,6 +2,7 @@ package services
import ( import (
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "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) { func TestParseTextBOMNameFromFilename(t *testing.T) {
const sample = "CPU Intel 6760P - 2 шт.\nMem 128G - 16 шт." const sample = "CPU Intel 6760P - 2 шт.\nMem 128G - 16 шт."
workspace, err := parseTextBOM([]byte(sample), "my-config.txt") workspace, err := parseTextBOM([]byte(sample), "my-config.txt")

View File

@@ -3054,62 +3054,44 @@ function _normalizeBomRawRows(rows) {
}); });
} }
function _parseInspurBOMText(text) { function _applyParsedBOMRows(parsed) {
const lines = text.split(/\r?\n/); if (!Array.isArray(parsed) || !parsed.length) return false;
const result = []; bomImportRaw = {
for (const raw of lines) { mode: 'raw',
const line = raw.trim(); rows: parsed,
if (!line) continue; columnTypes: ['pn', 'qty'],
const clean = line.startsWith('|') ? line.slice(1).trim() : line; ignoredRows: {},
if (!clean) continue; rowErrors: {},
const starIdx = clean.lastIndexOf('*'); uiError: ''
if (starIdx > 0) { };
const suffix = clean.slice(starIdx + 1).trim(); bomRows = [];
if (/^\d+$/.test(suffix)) { _setBomUIError('');
result.push([clean.slice(0, starIdx).trim(), suffix]); rebuildBOMRowsFromRaw();
continue; renderBOMTable();
} return true;
}
result.push([clean, '1']);
}
return result;
} }
function _isInspurBOMText(text) { // Detection and parsing of known single-column text BOM formats (Inspur, Russian
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0); // text BOM) lives only on the server (internal/services/vendor_workspace_import.go),
if (!lines.length) return false; // shared with the vendor file-import path. The paste handler asks the server to
let matches = 0; // parse; an unrecognized payload falls back to the generic Excel column grid below.
for (const line of lines) { async function _serverParseBOMText(text) {
const t = line.trim(); try {
const idx = t.lastIndexOf('*'); const resp = await fetch('/api/vendor-spec/parse-text', {
if (idx > 0 && /^\d+$/.test(t.slice(idx + 1).trim())) matches++; 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) { function _applyGenericBOMPaste(text) {
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;
}
const lines = text.split(/\r?\n/).filter(l => l.length > 0); const lines = text.split(/\r?\n/).filter(l => l.length > 0);
if (!lines.length) return; if (!lines.length) return;
const rows = lines.map(l => l.split('\t').map(c => c.trim())); const rows = lines.map(l => l.split('\t').map(c => c.trim()));
@@ -3129,6 +3111,20 @@ function handleBOMPaste(event) {
renderBOMTable(); 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() { function _getBomColumnTypeIndexes() {
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null; if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
const idx = { ignore: [], pn: [], qty: [], price: [], description: [] }; const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };