feat: импорт человекочитаемого текстового BOM (формат "<описание> - N шт.")
Новый формат vendor-import: опциональный заголовок "Сервер <модель>, в составе:" и строки вида "<описание> - <кол-во> шт." (дефис/тире, пробел перед "шт" и точка опциональны). Количество якорится в конце строки, поэтому дефисы и цифры внутри описания (8-GPU-2304GB) сохраняются. Описание пишется и в vendor_partnumber, и в description: строки резолвятся через активную книгу партномеров, иначе остаются нерезолвленными и редактируемыми. Весь файл — одна конфигурация. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -80,3 +80,31 @@ Rules:
|
|||||||
- configuration `name` is derived from the uploaded filename (without extension);
|
- configuration `name` is derived from the uploaded filename (without extension);
|
||||||
- lines that do not contain `*<digits>` are skipped;
|
- lines that do not contain `*<digits>` 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.
|
||||||
|
|
||||||
|
## Text BOM import
|
||||||
|
|
||||||
|
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
|
||||||
|
`<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,
|
||||||
|
so hyphens and digits inside the description (e.g. `8-GPU-2304GB`) are preserved.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
Сервер KR9288X3, в составе:
|
||||||
|
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 шт.
|
||||||
|
NVIDIA twin port transceiver, 800Gbps, OSFP - 8шт.
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- the entire file becomes a single configuration (`server_count = 1`);
|
||||||
|
- the `Сервер <model>, в составе:` header supplies the configuration `name` and `server_model`;
|
||||||
|
- 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 `<description> - <quantity> шт.` are skipped;
|
||||||
|
- no price data is present in the format; `unit_price` and `total_price` are left nil.
|
||||||
|
|||||||
@@ -1725,7 +1725,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
|
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) {
|
if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) && !services.IsTextBOM(data) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -134,6 +135,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
|||||||
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
|
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
|
||||||
case IsInspurBOM(data):
|
case IsInspurBOM(data):
|
||||||
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
|
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
|
||||||
|
case IsTextBOM(data):
|
||||||
|
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported vendor export format")
|
return nil, fmt.Errorf("unsupported vendor export format")
|
||||||
}
|
}
|
||||||
@@ -680,6 +683,93 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// textBOMItemLine matches a human-readable BOM line of the form
|
||||||
|
// "<description> - <quantity> шт." where the separator may be a hyphen,
|
||||||
|
// en-dash or em-dash and the quantity may have an optional space before "шт".
|
||||||
|
// The quantity anchor at the end keeps internal hyphens/digits in the
|
||||||
|
// 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+составе`)
|
||||||
|
|
||||||
|
// IsTextBOM reports whether data looks like a human-readable Russian text BOM,
|
||||||
|
// i.e. it contains at least one "<description> - <quantity> шт." line.
|
||||||
|
func IsTextBOM(data []byte) bool {
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
if textBOMItemLine.MatchString(strings.TrimSpace(raw)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTextBOM parses a human-readable Russian text BOM into a single configuration.
|
||||||
|
// The optional "Сервер <model>, в составе:" header provides the configuration name and
|
||||||
|
// server model. Each "<description> - <quantity> шт." line becomes one vendor spec row.
|
||||||
|
// The format carries no partnumbers, so rows stay unresolved and editable in the UI
|
||||||
|
// until mapped through the active partnumber book.
|
||||||
|
func parseTextBOM(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 {
|
||||||
|
serverModel = strings.TrimSpace(m[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := textBOMItemLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
description := strings.TrimSpace(m[1])
|
||||||
|
qty, err := strconv.Atoi(m[2])
|
||||||
|
if err != nil || qty <= 0 || 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("text BOM has no importable rows")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := serverModel
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "Text BOM Import"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &importedWorkspace{
|
||||||
|
SourceFormat: "Text",
|
||||||
|
SourceFileName: sourceFileName,
|
||||||
|
Configurations: []importedConfiguration{
|
||||||
|
{
|
||||||
|
GroupID: "text-0",
|
||||||
|
Name: name,
|
||||||
|
Line: 10,
|
||||||
|
ServerCount: 1,
|
||||||
|
ServerModel: serverModel,
|
||||||
|
Rows: rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export.
|
// IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export.
|
||||||
// The file starts (after optional UTF-8 BOM) with the header line:
|
// The file starts (after optional UTF-8 BOM) with the header line:
|
||||||
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
|
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
|
||||||
|
|||||||
@@ -588,3 +588,121 @@ func TestIsInspurBOM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsTextBOM(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"CPU Intel 6760P - 2 шт.", true},
|
||||||
|
{"Fan 18Krpm 8086 - 20 шт.\nRail L-Type 665mm - 1 шт.", true},
|
||||||
|
{"NVIDIA transceiver - 8шт.", true}, // no space before шт
|
||||||
|
{"Сервер KR9288X3, в составе:\nFan - 4 шт.", true},
|
||||||
|
{"|CPU_AMD*1\n|PSU*2", false}, // Inspur
|
||||||
|
{"<CFXML>\n</CFXML>", false},
|
||||||
|
{"just text\nno quantities", false},
|
||||||
|
{"CPU - 2 pcs.", false}, // not Russian шт
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := IsTextBOM([]byte(tc.input))
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("IsTextBOM(%q) = %v, want %v", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTextBOM(t *testing.T) {
|
||||||
|
const sample = `Сервер KR9288X3, в составе:
|
||||||
|
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
|
||||||
|
incl. onboard 800G XDR - 8 шт.
|
||||||
|
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
|
||||||
|
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
|
||||||
|
SSD 960G U.2 16GTps 2.5in RAID_1 - 2 шт.
|
||||||
|
SSD 3.84T U.2 16GTps 2.5in R-Standard - 2 шт.
|
||||||
|
NIC 25Gbps 2Port LC Nvidia CX6LX PCIe MM GEN4 - 1 шт.
|
||||||
|
PowerSupply 3200W Titanium 220VACor240VDC - 2 шт.
|
||||||
|
PowerSupply 3300W Titanium 220VACor240VDC - 6 шт.
|
||||||
|
PowerCord 1.9M C20 C19 - 14 шт.
|
||||||
|
Rail L-Type 665mm - 1 шт.
|
||||||
|
Chassis 2.5x12 gpu - 1 шт.
|
||||||
|
Fan 18Krpm 8086 - 20 шт.
|
||||||
|
NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top - 8шт.`
|
||||||
|
|
||||||
|
workspace, err := parseTextBOM([]byte(sample), "spec.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if workspace.SourceFormat != "Text" {
|
||||||
|
t.Fatalf("expected SourceFormat Text, got %q", workspace.SourceFormat)
|
||||||
|
}
|
||||||
|
if len(workspace.Configurations) != 1 {
|
||||||
|
t.Fatalf("expected 1 configuration, got %d", len(workspace.Configurations))
|
||||||
|
}
|
||||||
|
cfg := workspace.Configurations[0]
|
||||||
|
if cfg.Name != "KR9288X3" {
|
||||||
|
t.Fatalf("expected name KR9288X3 (from header), got %q", cfg.Name)
|
||||||
|
}
|
||||||
|
if cfg.ServerModel != "KR9288X3" {
|
||||||
|
t.Fatalf("expected ServerModel KR9288X3, got %q", cfg.ServerModel)
|
||||||
|
}
|
||||||
|
if cfg.ServerCount != 1 {
|
||||||
|
t.Fatalf("expected ServerCount 1, got %d", cfg.ServerCount)
|
||||||
|
}
|
||||||
|
const wantRows = 14
|
||||||
|
if len(cfg.Rows) != wantRows {
|
||||||
|
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
|
||||||
|
for _, r := range cfg.Rows {
|
||||||
|
rowsByDesc[r.Description] = r
|
||||||
|
if r.VendorPartnumber != r.Description {
|
||||||
|
t.Fatalf("expected VendorPartnumber to mirror Description, got pn=%q desc=%q", r.VendorPartnumber, r.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description with internal hyphens and digits must not be split early.
|
||||||
|
gpu, ok := rowsByDesc["GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected GPU row not found (check hyphen handling)")
|
||||||
|
}
|
||||||
|
if gpu.Quantity != 1 {
|
||||||
|
t.Fatalf("GPU: expected qty 1, got %d", gpu.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
mem, ok := rowsByDesc["Mem 128G DDR5-6400MHz ECC-RDIMM"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected Mem row not found")
|
||||||
|
}
|
||||||
|
if mem.Quantity != 16 {
|
||||||
|
t.Fatalf("Mem: expected qty 16, got %d", mem.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quantity with no space before "шт" and commas/hyphens in description.
|
||||||
|
xcvr, ok := rowsByDesc["NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up to 50m, flat top"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected transceiver row not found (check no-space quantity)")
|
||||||
|
}
|
||||||
|
if xcvr.Quantity != 8 {
|
||||||
|
t.Fatalf("transceiver: expected qty 8, got %d", xcvr.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTextBOMNameFromFilename(t *testing.T) {
|
||||||
|
const sample = "CPU Intel 6760P - 2 шт.\nMem 128G - 16 шт."
|
||||||
|
workspace, err := parseTextBOM([]byte(sample), "my-config.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
cfg := workspace.Configurations[0]
|
||||||
|
if cfg.Name != "my-config" {
|
||||||
|
t.Fatalf("expected name my-config (from filename), got %q", cfg.Name)
|
||||||
|
}
|
||||||
|
if cfg.ServerModel != "" {
|
||||||
|
t.Fatalf("expected empty ServerModel without header, got %q", cfg.ServerModel)
|
||||||
|
}
|
||||||
|
if len(cfg.Rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 rows, got %d", len(cfg.Rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user