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:
@@ -62,3 +62,21 @@ Imported configuration fields:
|
|||||||
- `article` or `support_code` from `ProprietaryProductIdentifier`
|
- `article` or `support_code` from `ProprietaryProductIdentifier`
|
||||||
|
|
||||||
Imported BOM rows become `vendor_spec` rows and are resolved through the active local partnumber book when possible.
|
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, `<partnumber>*<quantity>`. 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 `*<digits>` are skipped;
|
||||||
|
- no price data is present in the format; `unit_price` and `total_price` are left nil.
|
||||||
|
|||||||
+1
-1
@@ -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)
|
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !services.IsCFXMLWorkspace(data) {
|
if !services.IsCFXMLWorkspace(data) && !services.IsInspurBOM(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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,15 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
|||||||
return nil, ErrProjectNotFound
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -558,3 +566,90 @@ func normalizeTopLevelQuantity(raw string, serverCount int) int {
|
|||||||
func IsCFXMLWorkspace(data []byte) bool {
|
func IsCFXMLWorkspace(data []byte) bool {
|
||||||
return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -358,3 +358,127 @@ func TestImportVendorWorkspaceToProject_AutoResolvesAndAppliesEstimate(t *testin
|
|||||||
t.Fatalf("expected resolved rows for CPU and LIC in vendor spec")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2834,14 +2834,24 @@ async function refreshPrices() {
|
|||||||
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
|
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' });
|
let serverSyncSkipped = false;
|
||||||
if (!componentSyncResp.ok) {
|
try {
|
||||||
throw new Error('component sync failed');
|
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' });
|
const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' });
|
||||||
if (!pricelistSyncResp.ok) {
|
if (!pricelistSyncResp.ok) throw new Error('pricelist sync failed');
|
||||||
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([
|
await Promise.all([
|
||||||
@@ -2876,7 +2886,7 @@ async function refreshPrices() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('Цены обновлены', 'success');
|
showToast(serverSyncSkipped ? 'Цены обновлены (без связи с сервером)' : 'Цены обновлены', 'success');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
showToast('Ошибка обновления цен', 'error');
|
showToast('Ошибка обновления цен', 'error');
|
||||||
} finally {
|
} 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) {
|
function handleBOMPaste(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const text = event.clipboardData.getData('text/plain');
|
const text = event.clipboardData.getData('text/plain');
|
||||||
if (!text || !text.trim()) return;
|
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()));
|
||||||
|
|||||||
Reference in New Issue
Block a user