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:
Mikhail Chusavitin
2026-07-21 13:20:01 +03:00
co-authored by Claude Sonnet 5
parent 20e25927f2
commit b48436de93
2 changed files with 202 additions and 0 deletions
@@ -133,6 +133,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName)) workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
case IsQuoteForgeCSV(data): case IsQuoteForgeCSV(data):
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName)) workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
case IsQuoteForgePricingCSV(data):
workspace, err = parsePricingCSV(data, filepath.Base(sourceFileName))
case IsInspurBOM(data): case IsInspurBOM(data):
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName)) workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
case IsNxBOM(data): case IsNxBOM(data):
@@ -984,6 +986,124 @@ func parseQuoteForgeCSV(data []byte, sourceFileName string) (*importedWorkspace,
}, nil }, 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. // csvCol returns record[idx] or "" when idx is out of range.
func csvCol(record []string, idx int) string { func csvCol(record []string, idx int) string {
if idx < len(record) { 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) { func TestParseCSVPrice(t *testing.T) {
cases := []struct { cases := []struct {
input string input string