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))
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user