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