feat: Nx BOM import — формат <qty>x <description>
Добавлен новый вариант импорта спеки: quantity-first формат, где каждая строка начинается с `<qty>x <description>` (например, «2x Intel Xeon 8570»). Порядок детекции: Inspur → Nx → Text BOM. Заголовок «, в составе:» работает так же, как в Text BOM — последний токен перед запятой становится server_model. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,41 @@ Rules:
|
|||||||
- 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.
|
||||||
|
|
||||||
|
## Nx BOM import (quantity-first)
|
||||||
|
|
||||||
|
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a quantity-first BOM
|
||||||
|
where each item line begins with `<qty>x <description>`.
|
||||||
|
|
||||||
|
Format: an optional header line ending with `, в составе:` followed by one component per line as
|
||||||
|
`<qty>x <description>`. The `x` separator is case-insensitive; parentheses, commas, and hyphens
|
||||||
|
inside the description are preserved as-is.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
Сервер G893-SD1-AAX3, в составе:
|
||||||
|
1x 8U 2CPU 8GPU Server System (32x DDR5 DIMM Slots,8x 2.5" Hot-Swap Drive Bays, 4+4 3000W, 2x 10Gb/s RJ45, 2x IPMI RJ45)
|
||||||
|
2x Intel Xeon 8570 (56 cores, 2.1GHz, 300MB, 350W)
|
||||||
|
32x 64GB DDR5 ECC RDIMM
|
||||||
|
1x GPU Nvidia HGX H200 141GB 8GPU
|
||||||
|
3x 1.92TB NVMe PCIe SFF RI
|
||||||
|
5x 7.68TB NVMe PCIe SFF RI
|
||||||
|
8x 1-port 400G NDR OSFP CX7
|
||||||
|
2x 2-port 100GbE QSFP56 CX6
|
||||||
|
1x 2-port 10GbE RJ45
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- the entire file becomes a single configuration (`server_count = 1`);
|
||||||
|
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the
|
||||||
|
last whitespace-separated token before the comma;
|
||||||
|
- 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 `<qty>x <description>` are skipped;
|
||||||
|
- no price data is present in the format; `unit_price` and `total_price` are left nil;
|
||||||
|
- detection runs before Text BOM in the format switch (Inspur → Nx → Text).
|
||||||
|
|
||||||
## Pasted BOM text parsing
|
## Pasted BOM text parsing
|
||||||
|
|
||||||
`POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM
|
`POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM
|
||||||
@@ -119,7 +154,7 @@ Rules:
|
|||||||
`{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`.
|
`{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`.
|
||||||
|
|
||||||
This shares the exact detectors and parsers used by the file-import path
|
This shares the exact detectors and parsers used by the file-import path
|
||||||
(`ParsePastedBOMText` → `IsInspurBOM`/`parseInspurBOM`, `IsTextBOM`/`parseTextBOM`), so paste and upload
|
(`ParsePastedBOMText` → `IsInspurBOM`/`parseInspurBOM`, `IsNxBOM`/`parseNxBOM`, `IsTextBOM`/`parseTextBOM`),
|
||||||
behave identically — there is no second parser in the frontend. The configurator's BOM paste box calls
|
so paste and upload behave identically — there is no second parser in the frontend. The configurator's BOM
|
||||||
this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real spreadsheet table)
|
paste box calls this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real
|
||||||
falls back to the manual column-mapping grid.
|
spreadsheet table) falls back to the manual column-mapping grid.
|
||||||
|
|||||||
@@ -135,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 IsNxBOM(data):
|
||||||
|
workspace, err = parseNxBOM(data, filepath.Base(sourceFileName))
|
||||||
case IsTextBOM(data):
|
case IsTextBOM(data):
|
||||||
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
|
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
|
||||||
default:
|
default:
|
||||||
@@ -683,6 +685,93 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nxBOMItemLine matches a quantity-first BOM line: "<qty>x <description>"
|
||||||
|
// where the quantity prefix is digits followed immediately by "x" (case-insensitive).
|
||||||
|
// Parentheses, commas, and hyphens inside the description are preserved.
|
||||||
|
var nxBOMItemLine = regexp.MustCompile(`(?i)^(\d+)[xX]\s+(.+\S)\s*$`)
|
||||||
|
|
||||||
|
// IsNxBOM reports whether data looks like a quantity-first "Nx" BOM where each
|
||||||
|
// item line begins with "<qty>x <description>" (e.g. "2x Intel Xeon 8570 ...").
|
||||||
|
func IsNxBOM(data []byte) bool {
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
if nxBOMItemLine.MatchString(strings.TrimSpace(raw)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNxBOM parses a quantity-first "Nx" BOM into a single configuration.
|
||||||
|
// An optional header line ending with ", в составе:" supplies server_model and name.
|
||||||
|
// Each "<qty>x <description>" line becomes one vendor spec row; 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.
|
||||||
|
func parseNxBOM(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 {
|
||||||
|
if fields := strings.Fields(m[1]); len(fields) > 0 {
|
||||||
|
serverModel = fields[len(fields)-1]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := nxBOMItemLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil || qty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
description := strings.TrimSpace(m[2])
|
||||||
|
if 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("Nx BOM has no importable rows")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := serverModel
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "Nx BOM Import"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &importedWorkspace{
|
||||||
|
SourceFormat: "Nx",
|
||||||
|
SourceFileName: sourceFileName,
|
||||||
|
Configurations: []importedConfiguration{
|
||||||
|
{
|
||||||
|
GroupID: "nx-0",
|
||||||
|
Name: name,
|
||||||
|
Line: 10,
|
||||||
|
ServerCount: 1,
|
||||||
|
ServerModel: serverModel,
|
||||||
|
Rows: rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// textBOMItemLine matches a human-readable BOM line of the form
|
// textBOMItemLine matches a human-readable BOM line of the form
|
||||||
// "<description> - <quantity> шт." where the separator may be a hyphen,
|
// "<description> - <quantity> шт." where the separator may be a hyphen,
|
||||||
// en-dash or em-dash and the quantity may have an optional space before "шт".
|
// en-dash or em-dash and the quantity may have an optional space before "шт".
|
||||||
@@ -709,6 +798,8 @@ func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) {
|
|||||||
switch {
|
switch {
|
||||||
case IsInspurBOM(data):
|
case IsInspurBOM(data):
|
||||||
ws, err = parseInspurBOM(data, "")
|
ws, err = parseInspurBOM(data, "")
|
||||||
|
case IsNxBOM(data):
|
||||||
|
ws, err = parseNxBOM(data, "")
|
||||||
case IsTextBOM(data):
|
case IsTextBOM(data):
|
||||||
ws, err = parseTextBOM(data, "")
|
ws, err = parseTextBOM(data, "")
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user