feat: поддержка импорта CSV из pricing-экспорта (customer-facing формат)
Позволяет заново импортировать проект из CSV, экспортированного кнопкой "Экспорт CSV" (Line Item;LOT;PN вендора;...), а не только из round-trip формата (Line;Type;p/n;...). Колонки резолвятся по имени, т.к. набор опциональных столбцов зависит от настроек экспорта. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
20e25927f2
commit
b48436de93
@@ -133,6 +133,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
||||
workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
|
||||
case IsQuoteForgeCSV(data):
|
||||
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
|
||||
case IsQuoteForgePricingCSV(data):
|
||||
workspace, err = parsePricingCSV(data, filepath.Base(sourceFileName))
|
||||
case IsInspurBOM(data):
|
||||
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
|
||||
case IsNxBOM(data):
|
||||
@@ -984,6 +986,124 @@ func parseQuoteForgeCSV(data []byte, sourceFileName string) (*importedWorkspace,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsQuoteForgePricingCSV reports whether data looks like a QuoteForge project
|
||||
// pricing export (the customer-facing FOB/DDP CSV, distinct from the round-trip
|
||||
// "Line;Type;p/n" format). It starts (after optional UTF-8 BOM) with a header
|
||||
// whose first column is "Line Item". The optional columns (LOT, BOM, Estimate,
|
||||
// Stock, Конкуренты) vary by export settings, so parsing resolves columns by name.
|
||||
func IsQuoteForgePricingCSV(data []byte) bool {
|
||||
trimmed := bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
firstLine := trimmed
|
||||
if idx := bytes.IndexByte(trimmed, '\n'); idx >= 0 {
|
||||
firstLine = trimmed[:idx]
|
||||
}
|
||||
return bytes.HasPrefix(bytes.TrimSpace(firstLine), []byte("Line Item;"))
|
||||
}
|
||||
|
||||
// parsePricingCSV parses a QuoteForge project pricing export back into importable
|
||||
// configurations. Unlike parseQuoteForgeCSV, component lot names live in the "LOT"
|
||||
// column rather than "p/n" (which is empty/vendor part number in this format), so
|
||||
// the LOT column is required to reconstruct the BOM. Prices come from "Estimate"
|
||||
// (falling back to "BOM") since that column holds the price the export was based on.
|
||||
func parsePricingCSV(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
|
||||
r := csv.NewReader(bytes.NewReader(data))
|
||||
r.Comma = ';'
|
||||
r.FieldsPerRecord = -1
|
||||
r.LazyQuotes = true
|
||||
|
||||
records, err := r.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse pricing CSV: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, fmt.Errorf("pricing CSV is empty")
|
||||
}
|
||||
|
||||
col := make(map[string]int, len(records[0]))
|
||||
for i, name := range records[0] {
|
||||
col[strings.ToLower(strings.TrimSpace(name))] = i
|
||||
}
|
||||
|
||||
lineIdx, ok := col["line item"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("pricing CSV has no Line Item column")
|
||||
}
|
||||
lotIdx, hasLot := col["lot"]
|
||||
if !hasLot {
|
||||
return nil, fmt.Errorf("pricing CSV has no LOT column — cannot reconstruct components")
|
||||
}
|
||||
qtyIdx, hasQty := col["кол-во"]
|
||||
if !hasQty {
|
||||
return nil, fmt.Errorf("pricing CSV has no Кол-во column")
|
||||
}
|
||||
descIdx, hasDesc := col["описание"]
|
||||
estimateIdx, hasEstimate := col["estimate"]
|
||||
bomIdx, hasBOM := col["bom"]
|
||||
|
||||
var configs []importedConfiguration
|
||||
var current *importedConfiguration
|
||||
blockIdx := 0
|
||||
|
||||
for _, record := range records[1:] {
|
||||
if csvAllEmpty(record) {
|
||||
continue
|
||||
}
|
||||
lineCol := strings.TrimSpace(csvCol(record, lineIdx))
|
||||
lot := strings.TrimSpace(csvCol(record, lotIdx))
|
||||
|
||||
if lineCol != "" {
|
||||
if current != nil {
|
||||
configs = append(configs, *current)
|
||||
}
|
||||
blockIdx++
|
||||
name := ""
|
||||
if hasDesc {
|
||||
name = strings.TrimSpace(csvCol(record, descIdx))
|
||||
}
|
||||
if name == "" || name == "—" {
|
||||
name = fmt.Sprintf("Config %d", blockIdx)
|
||||
}
|
||||
serverCount := maxInt(parseInt(strings.TrimSpace(csvCol(record, qtyIdx))), 1)
|
||||
current = &importedConfiguration{
|
||||
GroupID: fmt.Sprintf("pricingcsv-%d", blockIdx),
|
||||
Name: name,
|
||||
Line: blockIdx * 10,
|
||||
ServerCount: serverCount,
|
||||
DirectItems: make(localdb.LocalConfigItems, 0),
|
||||
}
|
||||
} else if lot != "" && lot != "—" && current != nil {
|
||||
qty := maxInt(parseInt(strings.TrimSpace(csvCol(record, qtyIdx))), 1)
|
||||
unitPrice := 0.0
|
||||
if hasEstimate {
|
||||
unitPrice = parseCSVPrice(strings.TrimSpace(csvCol(record, estimateIdx)))
|
||||
}
|
||||
if unitPrice == 0 && hasBOM {
|
||||
unitPrice = parseCSVPrice(strings.TrimSpace(csvCol(record, bomIdx)))
|
||||
}
|
||||
current.DirectItems = append(current.DirectItems, localdb.LocalConfigItem{
|
||||
LotName: lot,
|
||||
Quantity: qty,
|
||||
UnitPrice: unitPrice,
|
||||
})
|
||||
}
|
||||
}
|
||||
if current != nil {
|
||||
configs = append(configs, *current)
|
||||
}
|
||||
|
||||
if len(configs) == 0 {
|
||||
return nil, fmt.Errorf("pricing CSV has no importable configurations")
|
||||
}
|
||||
|
||||
return &importedWorkspace{
|
||||
SourceFormat: "PricingCSV",
|
||||
SourceFileName: sourceFileName,
|
||||
Configurations: configs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// csvCol returns record[idx] or "" when idx is out of range.
|
||||
func csvCol(record []string, idx int) string {
|
||||
if idx < len(record) {
|
||||
|
||||
@@ -542,6 +542,88 @@ func TestIsQuoteForgeCSV(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePricingCSV(t *testing.T) {
|
||||
// Format mirrors pricingCSVHeaders/pricingCSVRow output: the customer-facing
|
||||
// pricing export, distinct from ToCSV's "Line;Type;p/n" round-trip format.
|
||||
const sample = "\xEF\xBB\xBF" + // UTF-8 BOM
|
||||
"Line Item;LOT;PN вендора;Описание;Кол-во;BOM;Estimate;Stock;Конкуренты\n" +
|
||||
"10;;;5_ООО+РН-Бурение_NOL100582279;3;—;18 562,00;3 590,18;17 983,85\n" +
|
||||
";MB_AMD_4.Genoa_2S;—;—;1;—;3 906,00;—;—\n" +
|
||||
";CPU_AMD_9224;—;—;2;—;2 000,00;—;2 266,66\n" +
|
||||
"\n" +
|
||||
"20;;;11_АО+Ангарскнефтехимпроект_NOL100574528;2;—;30 283,00;12 889,60;32 254,39\n" +
|
||||
";MEM_DDR5_64G_5600;—;—;8;—;17 200,00;8 032,56;23 384,32\n"
|
||||
|
||||
workspace, err := parsePricingCSV([]byte(sample), "pricing.csv")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if workspace.SourceFormat != "PricingCSV" {
|
||||
t.Fatalf("expected SourceFormat PricingCSV, got %q", workspace.SourceFormat)
|
||||
}
|
||||
if len(workspace.Configurations) != 2 {
|
||||
t.Fatalf("expected 2 configurations, got %d", len(workspace.Configurations))
|
||||
}
|
||||
|
||||
cfg1 := workspace.Configurations[0]
|
||||
if cfg1.Name != "5_ООО+РН-Бурение_NOL100582279" {
|
||||
t.Fatalf("cfg1 name: got %q", cfg1.Name)
|
||||
}
|
||||
if cfg1.ServerCount != 3 {
|
||||
t.Fatalf("cfg1 server_count: want 3, got %d", cfg1.ServerCount)
|
||||
}
|
||||
if len(cfg1.DirectItems) != 2 {
|
||||
t.Fatalf("cfg1 items: want 2, got %d", len(cfg1.DirectItems))
|
||||
}
|
||||
if cfg1.DirectItems[0].LotName != "MB_AMD_4.Genoa_2S" || cfg1.DirectItems[0].Quantity != 1 || cfg1.DirectItems[0].UnitPrice != 3906 {
|
||||
t.Fatalf("cfg1 item[0]: %+v", cfg1.DirectItems[0])
|
||||
}
|
||||
if cfg1.DirectItems[1].LotName != "CPU_AMD_9224" || cfg1.DirectItems[1].Quantity != 2 || cfg1.DirectItems[1].UnitPrice != 2000 {
|
||||
t.Fatalf("cfg1 item[1]: %+v", cfg1.DirectItems[1])
|
||||
}
|
||||
|
||||
cfg2 := workspace.Configurations[1]
|
||||
if cfg2.Name != "11_АО+Ангарскнефтехимпроект_NOL100574528" {
|
||||
t.Fatalf("cfg2 name: got %q", cfg2.Name)
|
||||
}
|
||||
if cfg2.ServerCount != 2 {
|
||||
t.Fatalf("cfg2 server_count: want 2, got %d", cfg2.ServerCount)
|
||||
}
|
||||
if len(cfg2.DirectItems) != 1 || cfg2.DirectItems[0].LotName != "MEM_DDR5_64G_5600" {
|
||||
t.Fatalf("cfg2 items: %+v", cfg2.DirectItems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQuoteForgePricingCSV(t *testing.T) {
|
||||
withBOM := "\xEF\xBB\xBFLine Item;LOT;PN вендора;Описание;Кол-во;BOM;Estimate;Stock;Конкуренты\n10;;;name;1;—;100;—;—\n"
|
||||
noLOT := "Line Item;PN вендора;Описание;Кол-во;BOM;Estimate;Stock;Конкуренты\n10;;name;1;—;100;—;—\n"
|
||||
|
||||
cases := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{withBOM, true},
|
||||
{noLOT, true},
|
||||
{"Line;Type;p/n;Description\n", false},
|
||||
{"<CFXML>\n</CFXML>", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := IsQuoteForgePricingCSV([]byte(tc.input))
|
||||
if got != tc.want {
|
||||
t.Errorf("IsQuoteForgePricingCSV(%q) = %v, want %v", tc.input[:min(len(tc.input), 40)], got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePricingCSV_NoLOTColumn(t *testing.T) {
|
||||
const sample = "Line Item;PN вендора;Описание;Кол-во;BOM;Estimate;Stock;Конкуренты\n" +
|
||||
"10;;name;1;—;100;—;—\n"
|
||||
if _, err := parsePricingCSV([]byte(sample), "pricing.csv"); err == nil {
|
||||
t.Fatal("expected error when LOT column is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVPrice(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
|
||||
Reference in New Issue
Block a user