feat: импорт собственного CSV QuoteForge + fix обновления цен
Добавлен парсер собственного CSV-экспорта QuoteForge (IsQuoteForgeCSV / parseQuoteForgeCSV). Формат: UTF-8 BOM + заголовок Line;Type;p/n;..., блоки сервер → компоненты. DirectItems создаются напрямую без прохода через VendorSpecResolver. Модальное окно импорта принимает .csv/.txt/.xml. Fix кнопки «Обновить цены» на странице варианта: после синхронизации прайс-листов запрашивается актуальный estimate-прайслист и передаётся явным pricelist_id в каждый POST /api/configs/:uuid/refresh-prices. Ранее использовался устаревший ID, сохранённый в конфигурации. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
@@ -47,7 +48,8 @@ type importedConfiguration struct {
|
||||
ServerModel string
|
||||
Article string
|
||||
CurrencyCode string
|
||||
Rows []localdb.VendorSpecItem
|
||||
Rows []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur)
|
||||
DirectItems localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV)
|
||||
TotalPrice *float64
|
||||
}
|
||||
|
||||
@@ -128,6 +130,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
||||
switch {
|
||||
case IsCFXMLWorkspace(data):
|
||||
workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
|
||||
case IsQuoteForgeCSV(data):
|
||||
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
|
||||
case IsInspurBOM(data):
|
||||
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
|
||||
default:
|
||||
@@ -148,10 +152,28 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
||||
for _, imported := range workspace.Configurations {
|
||||
now := time.Now()
|
||||
cfgUUID := uuid.NewString()
|
||||
groupRows, items, totalPrice, estimatePricelistID, err := s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, err)
|
||||
|
||||
var groupRows localdb.VendorSpec
|
||||
var items localdb.LocalConfigItems
|
||||
var totalPrice *float64
|
||||
var estimatePricelistID *uint
|
||||
|
||||
if len(imported.DirectItems) > 0 {
|
||||
items = imported.DirectItems
|
||||
estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate")
|
||||
if estimatePricelist != nil {
|
||||
estimatePricelistID = &estimatePricelist.ServerID
|
||||
}
|
||||
val := items.Total() * float64(maxInt(imported.ServerCount, 1))
|
||||
totalPrice = &val
|
||||
} else {
|
||||
var prepErr error
|
||||
groupRows, items, totalPrice, estimatePricelistID, prepErr = s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
|
||||
if prepErr != nil {
|
||||
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, prepErr)
|
||||
}
|
||||
}
|
||||
|
||||
localCfg := &localdb.LocalConfiguration{
|
||||
UUID: cfgUUID,
|
||||
ProjectUUID: &projectUUID,
|
||||
@@ -653,3 +675,135 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export.
|
||||
// The file starts (after optional UTF-8 BOM) with the header line:
|
||||
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
|
||||
func IsQuoteForgeCSV(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;Type;p/n;"))
|
||||
}
|
||||
|
||||
// parseQuoteForgeCSV parses a QuoteForge own CSV export back into importable configurations.
|
||||
// Each server block (row where Line column is non-empty) becomes one importedConfiguration
|
||||
// with DirectItems populated from the component rows that follow it.
|
||||
func parseQuoteForgeCSV(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 QuoteForge CSV: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, fmt.Errorf("QuoteForge CSV is empty")
|
||||
}
|
||||
|
||||
// Skip header row (first row whose first cell is "Line")
|
||||
startIdx := 0
|
||||
if len(records[0]) > 0 && strings.EqualFold(strings.TrimSpace(records[0][0]), "line") {
|
||||
startIdx = 1
|
||||
}
|
||||
|
||||
var configs []importedConfiguration
|
||||
var current *importedConfiguration
|
||||
blockIdx := 0
|
||||
|
||||
for _, record := range records[startIdx:] {
|
||||
if csvAllEmpty(record) {
|
||||
continue
|
||||
}
|
||||
lineCol := strings.TrimSpace(csvCol(record, 0))
|
||||
pn := strings.TrimSpace(csvCol(record, 2))
|
||||
|
||||
if lineCol != "" {
|
||||
// New server block
|
||||
if current != nil {
|
||||
configs = append(configs, *current)
|
||||
}
|
||||
blockIdx++
|
||||
serverCount := maxInt(parseInt(strings.TrimSpace(csvCol(record, 5))), 1)
|
||||
article := pn
|
||||
name := article
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("Config %d", blockIdx)
|
||||
}
|
||||
current = &importedConfiguration{
|
||||
GroupID: fmt.Sprintf("qfcsv-%d", blockIdx),
|
||||
Name: name,
|
||||
Line: blockIdx * 10,
|
||||
ServerCount: serverCount,
|
||||
Article: article,
|
||||
DirectItems: make(localdb.LocalConfigItems, 0),
|
||||
}
|
||||
} else if pn != "" && current != nil {
|
||||
// Component row
|
||||
qty := maxInt(parseInt(strings.TrimSpace(csvCol(record, 4))), 1)
|
||||
unitPrice := parseCSVPrice(strings.TrimSpace(csvCol(record, 6)))
|
||||
current.DirectItems = append(current.DirectItems, localdb.LocalConfigItem{
|
||||
LotName: pn,
|
||||
Quantity: qty,
|
||||
UnitPrice: unitPrice,
|
||||
})
|
||||
}
|
||||
}
|
||||
if current != nil {
|
||||
configs = append(configs, *current)
|
||||
}
|
||||
|
||||
if len(configs) == 0 {
|
||||
return nil, fmt.Errorf("QuoteForge CSV has no importable configurations")
|
||||
}
|
||||
|
||||
return &importedWorkspace{
|
||||
SourceFormat: "QuoteForgeCSV",
|
||||
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) {
|
||||
return record[idx]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// csvAllEmpty reports whether every cell in the record is blank.
|
||||
func csvAllEmpty(record []string) bool {
|
||||
for _, cell := range record {
|
||||
if strings.TrimSpace(cell) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parseCSVPrice parses a price string in QuoteForge CSV format:
|
||||
// comma as decimal separator, optional space as thousands separator.
|
||||
// Returns 0 on any parse failure.
|
||||
func parseCSVPrice(s string) float64 {
|
||||
if s == "" || s == "—" {
|
||||
return 0
|
||||
}
|
||||
// Remove thousands separators (space, non-breaking space)
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
// Replace comma decimal separator with dot
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user