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))
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) {