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:
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -134,6 +135,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
||||
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
|
||||
case IsInspurBOM(data):
|
||||
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
|
||||
case IsTextBOM(data):
|
||||
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported vendor export format")
|
||||
}
|
||||
@@ -680,6 +683,93 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
|
||||
}, 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.
|
||||
// The file starts (after optional UTF-8 BOM) with the header line:
|
||||
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
|
||||
|
||||
Reference in New Issue
Block a user