From 67a761345f277a400e9f05c0aa00e017026ab259 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sun, 24 May 2026 17:04:10 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=BA=D0=B0=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B0?= =?UTF-8?q?=20BOM=20Inspur=20=D0=B2=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=20PN*qty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен парсер для текстового формата Inspur (опциональный '|' в начале строки, разделитель '*' перед количеством). На BOM-вкладке вставка такого текста автоматически определяется и разбивается на колонки P/N + Qty без ручного выбора типов. На бэкенде тот же формат поддерживается через POST /api/projects/:uuid/vendor-import. Co-Authored-By: Claude Sonnet 4.6 --- bible-local/09-vendor-spec.md | 18 +++ cmd/qfs/main.go | 2 +- internal/services/vendor_workspace_import.go | 97 +++++++++++++- .../services/vendor_workspace_import_test.go | 124 ++++++++++++++++++ web/templates/index.html | 77 +++++++++-- 5 files changed, 308 insertions(+), 10 deletions(-) diff --git a/bible-local/09-vendor-spec.md b/bible-local/09-vendor-spec.md index e6d6800..68ec9de 100644 --- a/bible-local/09-vendor-spec.md +++ b/bible-local/09-vendor-spec.md @@ -62,3 +62,21 @@ Imported configuration fields: - `article` or `support_code` from `ProprietaryProductIdentifier` Imported BOM rows become `vendor_spec` rows and are resolved through the active local partnumber book when possible. + +## Inspur BOM import + +The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts Inspur text BOM exports. + +Format: one component per line, `*`. A leading `|` character is optional and stripped. Trailing whitespace around `*` is normalised. + +Example: +``` +|CPU_AMD_9535-EPYC2.4_64C_256M_300W*1 +|PowerSupply_1300W_Titanium_220VACor240VDC_GaN*2 +``` + +Rules: +- the entire file becomes a single configuration (`server_count = 1`); +- configuration `name` is derived from the uploaded filename (without extension); +- lines that do not contain `*` are skipped; +- no price data is present in the format; `unit_price` and `total_price` are left nil. diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 51f54a7..de01a0c 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1720,7 +1720,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge) return } - if !services.IsCFXMLWorkspace(data) { + if !services.IsCFXMLWorkspace(data) && !services.IsInspurBOM(data) { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"}) return } diff --git a/internal/services/vendor_workspace_import.go b/internal/services/vendor_workspace_import.go index 7e38446..8d555f9 100644 --- a/internal/services/vendor_workspace_import.go +++ b/internal/services/vendor_workspace_import.go @@ -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("")) || bytes.Contains(data, []byte(" 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 +} diff --git a/internal/services/vendor_workspace_import_test.go b/internal/services/vendor_workspace_import_test.go index c385264..d8e8a79 100644 --- a/internal/services/vendor_workspace_import_test.go +++ b/internal/services/vendor_workspace_import_test.go @@ -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}, + {"\n", 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) + } + } +} diff --git a/web/templates/index.html b/web/templates/index.html index 3094c13..bb5a647 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2834,14 +2834,24 @@ async function refreshPrices() { refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed'; } - const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' }); - if (!componentSyncResp.ok) { - throw new Error('component sync failed'); - } + let serverSyncSkipped = false; + try { + const statusResp = await fetch('/api/sync/status'); + const statusData = statusResp.ok ? await statusResp.json() : null; + if (statusData && statusData.is_online) { + const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' }); + if (!componentSyncResp.ok) throw new Error('component sync failed'); - const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' }); - if (!pricelistSyncResp.ok) { - throw new Error('pricelist sync failed'); + const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' }); + if (!pricelistSyncResp.ok) throw new Error('pricelist sync failed'); + } else { + serverSyncSkipped = true; + } + } catch(syncErr) { + if (syncErr.message === 'component sync failed' || syncErr.message === 'pricelist sync failed') { + throw syncErr; + } + serverSyncSkipped = true; } await Promise.all([ @@ -2876,7 +2886,7 @@ async function refreshPrices() { } } - showToast('Цены обновлены', 'success'); + showToast(serverSyncSkipped ? 'Цены обновлены (без связи с сервером)' : 'Цены обновлены', 'success'); } catch(e) { showToast('Ошибка обновления цен', 'error'); } finally { @@ -3046,11 +3056,62 @@ function _normalizeBomRawRows(rows) { }); } +function _parseInspurBOMText(text) { + const lines = text.split(/\r?\n/); + const result = []; + for (const raw of lines) { + const line = raw.trim(); + if (!line) continue; + const clean = line.startsWith('|') ? line.slice(1).trim() : line; + if (!clean) continue; + const starIdx = clean.lastIndexOf('*'); + if (starIdx > 0) { + const suffix = clean.slice(starIdx + 1).trim(); + if (/^\d+$/.test(suffix)) { + result.push([clean.slice(0, starIdx).trim(), suffix]); + continue; + } + } + result.push([clean, '1']); + } + return result; +} + +function _isInspurBOMText(text) { + const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0); + if (!lines.length) return false; + let matches = 0; + for (const line of lines) { + const t = line.trim(); + const idx = t.lastIndexOf('*'); + if (idx > 0 && /^\d+$/.test(t.slice(idx + 1).trim())) matches++; + } + return matches > 0 && matches >= Math.ceil(lines.length * 0.5); +} + function handleBOMPaste(event) { 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); if (!lines.length) return; const rows = lines.map(l => l.split('\t').map(c => c.trim()));