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:
2026-05-24 18:54:08 +03:00
parent 6b56cad248
commit 5d4e1b44f6
3 changed files with 289 additions and 10 deletions

View File

@@ -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
}