feat: поддержка импорта BOM Inspur в формате PN*qty

Добавлен парсер для текстового формата Inspur (опциональный '|' в начале
строки, разделитель '*' перед количеством). На BOM-вкладке вставка такого
текста автоматически определяется и разбивается на колонки P/N + Qty без
ручного выбора типов. На бэкенде тот же формат поддерживается через
POST /api/projects/:uuid/vendor-import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 17:04:10 +03:00
parent 55acbe138b
commit 67a761345f
5 changed files with 308 additions and 10 deletions

View File

@@ -124,7 +124,15 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
return nil, ErrProjectNotFound
}
workspace, err := parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
var workspace *importedWorkspace
switch {
case IsCFXMLWorkspace(data):
workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
case IsInspurBOM(data):
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
default:
return nil, fmt.Errorf("unsupported vendor export format")
}
if err != nil {
return nil, err
}
@@ -558,3 +566,90 @@ func normalizeTopLevelQuantity(raw string, serverCount int) int {
func IsCFXMLWorkspace(data []byte) bool {
return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
}
func IsInspurBOM(data []byte) bool {
for _, line := range bytes.Split(data, []byte("\n")) {
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 {
continue
}
idx := bytes.LastIndexByte(trimmed, '*')
if idx <= 0 {
continue
}
suffix := bytes.TrimSpace(trimmed[idx+1:])
if len(suffix) > 0 && allDigits(suffix) {
return true
}
}
return false
}
func allDigits(b []byte) bool {
if len(b) == 0 {
return false
}
for _, c := range b {
if c < '0' || c > '9' {
return false
}
}
return true
}
func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
lines := strings.Split(string(data), "\n")
rows := make([]localdb.VendorSpecItem, 0, len(lines))
sortOrder := 10
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
line = strings.TrimPrefix(line, "|")
line = strings.TrimSpace(line)
if line == "" {
continue
}
pn := line
qty := 1
if idx := strings.LastIndex(line, "*"); idx > 0 {
suffix := strings.TrimSpace(line[idx+1:])
if n, err := strconv.Atoi(suffix); err == nil && n > 0 {
pn = strings.TrimSpace(line[:idx])
qty = n
}
}
if pn == "" {
continue
}
rows = append(rows, localdb.VendorSpecItem{
SortOrder: sortOrder,
VendorPartnumber: pn,
Quantity: qty,
})
sortOrder += 10
}
if len(rows) == 0 {
return nil, fmt.Errorf("Inspur BOM has no importable rows")
}
name := strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
if name == "" {
name = "Inspur Import"
}
return &importedWorkspace{
SourceFormat: "Inspur",
SourceFileName: sourceFileName,
Configurations: []importedConfiguration{
{
GroupID: "inspur-0",
Name: name,
Line: 10,
ServerCount: 1,
Rows: rows,
},
},
}, nil
}

View File

@@ -358,3 +358,127 @@ func TestImportVendorWorkspaceToProject_AutoResolvesAndAppliesEstimate(t *testin
t.Fatalf("expected resolved rows for CPU and LIC in vendor spec")
}
}
func TestParseInspurBOM(t *testing.T) {
const sample = `|CPU_AMD_9535-EPYC2.4_64C_256M_300W*1
|Mem_64G_DDR5-6400MHz_ECC-RDIMM*1
|2.5 NVMe Bays*4
|2.5 or 3.5 SATA Bays*8
|FrontHDModuleBackPlane_4SAS or 4U.2_3.5x4_GEN5*1
|FrontHDModuleBackPlane_4SAS or 4U.2_3.5x4_GEN5*2
|RAID_IAG_2RO_9230_N_M.2_PCIE2_HS *1
|SSD_SA_480M2TD_MZNL3480HCLR_T2_6_PM893*2
|NIC_100Gbps_2Port_LC_Nvidia_CX6DX_PCIe_GEN4*1
|Riser_X16+X8+X8_G5-J4J6-A*1
|PowerSupply_1300W_Titanium_220VACor240VDC_GaN*2
|PowerCord_1.5m_C14_C13_CN+CNHK+CNTW+US+UK+EU+AU+SG+ZA+RU+KR*2
|Rail_Slider-Drop-in_760mm_2U-EN*1
|PKACCY_470x285x63_Box-Blankspace_General*1
|Chassis_3.5x12_6PCIE*1
|MB_AMD_Non*1
|Fan_23000rpm_6056*6
|Software-KSManage*1
|TPM_2.0_NON-MainLand_SPI-INF*1
|【CA&SA】KR2180E3-A0 3 years RTV HK Service*1
|【CA&SA】KR2180E3-A0 3 years Data Media Retention Service*1`
workspace, err := parseInspurBOM([]byte(sample), "KR2180E3-A0.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if workspace.SourceFormat != "Inspur" {
t.Fatalf("expected SourceFormat Inspur, 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 != "KR2180E3-A0" {
t.Fatalf("expected name KR2180E3-A0, got %q", cfg.Name)
}
if cfg.ServerCount != 1 {
t.Fatalf("expected ServerCount 1, got %d", cfg.ServerCount)
}
const wantRows = 21
if len(cfg.Rows) != wantRows {
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
}
rowsByPN := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
for _, r := range cfg.Rows {
rowsByPN[r.VendorPartnumber] = r
}
cpu, ok := rowsByPN["CPU_AMD_9535-EPYC2.4_64C_256M_300W"]
if !ok {
t.Fatal("expected CPU row not found")
}
if cpu.Quantity != 1 {
t.Fatalf("CPU: expected qty 1, got %d", cpu.Quantity)
}
psu, ok := rowsByPN["PowerSupply_1300W_Titanium_220VACor240VDC_GaN"]
if !ok {
t.Fatal("expected PSU row not found")
}
if psu.Quantity != 2 {
t.Fatalf("PSU: expected qty 2, got %d", psu.Quantity)
}
fan, ok := rowsByPN["Fan_23000rpm_6056"]
if !ok {
t.Fatal("expected Fan row not found")
}
if fan.Quantity != 6 {
t.Fatalf("Fan: expected qty 6, got %d", fan.Quantity)
}
// RAID partnumber has trailing space before *, must be trimmed
raid, ok := rowsByPN["RAID_IAG_2RO_9230_N_M.2_PCIE2_HS"]
if !ok {
t.Fatal("expected RAID row not found (check whitespace trimming)")
}
if raid.Quantity != 1 {
t.Fatalf("RAID: expected qty 1, got %d", raid.Quantity)
}
}
func TestParseInspurBOMWithoutPipe(t *testing.T) {
const sample = `CPU_AMD_9535*2
Mem_64G_DDR5*4
PowerSupply_1300W*2`
workspace, err := parseInspurBOM([]byte(sample), "config.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(workspace.Configurations[0].Rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(workspace.Configurations[0].Rows))
}
if workspace.Configurations[0].Rows[0].VendorPartnumber != "CPU_AMD_9535" {
t.Fatalf("unexpected pn: %q", workspace.Configurations[0].Rows[0].VendorPartnumber)
}
if workspace.Configurations[0].Rows[0].Quantity != 2 {
t.Fatalf("unexpected qty: %d", workspace.Configurations[0].Rows[0].Quantity)
}
}
func TestIsInspurBOM(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"|CPU_AMD*1\n|PSU*2", true},
{"CPU_AMD*1", true},
{"<CFXML>\n</CFXML>", false},
{"just text\nno stars", false},
{"pn*abc", false},
{"", false},
}
for _, tc := range cases {
got := IsInspurBOM([]byte(tc.input))
if got != tc.want {
t.Errorf("IsInspurBOM(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}