Files
QuoteForge/internal/services/vendor_workspace_import.go
Mikhail Chusavitin 7233a0780f feat: импорт человекочитаемого текстового BOM (формат "<описание> - N шт.")
Новый формат vendor-import: опциональный заголовок "Сервер <модель>,
в составе:" и строки вида "<описание> - <кол-во> шт." (дефис/тире,
пробел перед "шт" и точка опциональны). Количество якорится в конце
строки, поэтому дефисы и цифры внутри описания (8-GPU-2304GB) сохраняются.

Описание пишется и в vendor_partnumber, и в description: строки
резолвятся через активную книгу партномеров, иначе остаются
нерезолвленными и редактируемыми. Весь файл — одна конфигурация.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:06:26 +03:00

904 lines
26 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"bytes"
"encoding/csv"
"encoding/xml"
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
"gorm.io/gorm"
)
type VendorWorkspaceImportResult struct {
Imported int `json:"imported"`
Project *models.Project `json:"project,omitempty"`
Configs []VendorWorkspaceImportedConfig `json:"configs"`
}
type VendorWorkspaceImportedConfig struct {
UUID string `json:"uuid"`
Name string `json:"name"`
ServerCount int `json:"server_count"`
ServerModel string `json:"server_model,omitempty"`
Rows int `json:"rows"`
}
type importedWorkspace struct {
SourceFormat string
SourceDocID string
SourceFileName string
CurrencyCode string
Configurations []importedConfiguration
}
type importedConfiguration struct {
GroupID string
Name string
Line int
ServerCount int
ServerModel string
Article string
CurrencyCode string
Rows []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur)
DirectItems localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV)
TotalPrice *float64
}
type groupedItem struct {
order int
row cfxmlProductLineItem
}
type cfxmlDocument struct {
XMLName xml.Name `xml:"CFXML"`
ThisDocumentIdentifier cfxmlDocumentIdentifier `xml:"thisDocumentIdentifier"`
CFData cfxmlData `xml:"CFData"`
}
type cfxmlDocumentIdentifier struct {
ProprietaryDocumentIdentifier string `xml:"ProprietaryDocumentIdentifier"`
}
type cfxmlData struct {
ProprietaryInformation []cfxmlProprietaryInformation `xml:"ProprietaryInformation"`
ProductLineItems []cfxmlProductLineItem `xml:"ProductLineItem"`
}
type cfxmlProprietaryInformation struct {
Name string `xml:"Name"`
Value string `xml:"Value"`
}
type cfxmlProductLineItem struct {
ProductLineNumber string `xml:"ProductLineNumber"`
ItemNo string `xml:"ItemNo"`
TransactionType string `xml:"TransactionType"`
ProprietaryGroupIdentifier string `xml:"ProprietaryGroupIdentifier"`
ConfigurationGroupLineNumberReference string `xml:"ConfigurationGroupLineNumberReference"`
Quantity string `xml:"Quantity"`
ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"`
UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"`
ProductSubLineItems []cfxmlProductSubLineItem `xml:"ProductSubLineItem"`
}
type cfxmlProductSubLineItem struct {
LineNumber string `xml:"LineNumber"`
TransactionType string `xml:"TransactionType"`
Quantity string `xml:"Quantity"`
ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"`
UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"`
}
type cfxmlProductIdentification struct {
PartnerProductIdentification cfxmlPartnerProductIdentification `xml:"PartnerProductIdentification"`
}
type cfxmlPartnerProductIdentification struct {
ProprietaryProductIdentifier string `xml:"ProprietaryProductIdentifier"`
ProprietaryProductChar string `xml:"ProprietaryProductChar"`
ProductCharacter string `xml:"ProductCharacter"`
ProductDescription string `xml:"ProductDescription"`
ProductName string `xml:"ProductName"`
ProductTypeCode string `xml:"ProductTypeCode"`
}
type cfxmlUnitListPrice struct {
FinancialAmount cfxmlFinancialAmount `xml:"FinancialAmount"`
}
type cfxmlFinancialAmount struct {
GlobalCurrencyCode string `xml:"GlobalCurrencyCode"`
MonetaryAmount string `xml:"MonetaryAmount"`
}
func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID string, sourceFileName string, data []byte, ownerUsername string) (*VendorWorkspaceImportResult, error) {
project, err := s.localDB.GetProjectByUUID(projectUUID)
if err != nil {
return nil, ErrProjectNotFound
}
var workspace *importedWorkspace
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))
case IsTextBOM(data):
workspace, err = parseTextBOM(data, filepath.Base(sourceFileName))
default:
return nil, fmt.Errorf("unsupported vendor export format")
}
if err != nil {
return nil, err
}
result := &VendorWorkspaceImportResult{
Imported: 0,
Project: localdb.LocalToProject(project),
Configs: make([]VendorWorkspaceImportedConfig, 0, len(workspace.Configurations)),
}
err = s.localDB.DB().Transaction(func(tx *gorm.DB) error {
bookRepo := repository.NewPartnumberBookRepository(tx)
for _, imported := range workspace.Configurations {
now := time.Now()
cfgUUID := uuid.NewString()
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,
IsActive: true,
Name: imported.Name,
Items: items,
TotalPrice: totalPrice,
ServerCount: imported.ServerCount,
ServerModel: imported.ServerModel,
Article: imported.Article,
PricelistID: estimatePricelistID,
VendorSpec: groupRows,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "pending",
OriginalUsername: ownerUsername,
}
if err := s.createWithVersionTx(tx, localCfg, ownerUsername); err != nil {
return fmt.Errorf("import configuration group %s: %w", imported.GroupID, err)
}
result.Imported++
result.Configs = append(result.Configs, VendorWorkspaceImportedConfig{
UUID: localCfg.UUID,
Name: localCfg.Name,
ServerCount: localCfg.ServerCount,
ServerModel: localCfg.ServerModel,
Rows: len(localCfg.VendorSpec),
})
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb.VendorSpecItem, serverCount int, bookRepo *repository.PartnumberBookRepository) (localdb.VendorSpec, localdb.LocalConfigItems, *float64, *uint, error) {
resolver := NewVendorSpecResolver(bookRepo)
resolved, err := resolver.Resolve(append([]localdb.VendorSpecItem(nil), rows...))
if err != nil {
return nil, nil, nil, nil, err
}
canonical := make(localdb.VendorSpec, 0, len(resolved))
for _, row := range resolved {
if len(row.LotMappings) == 0 && strings.TrimSpace(row.ResolvedLotName) != "" {
row.LotMappings = []localdb.VendorSpecLotMapping{
{LotName: strings.TrimSpace(row.ResolvedLotName), QuantityPerPN: 1},
}
}
row.LotMappings = normalizeImportedLotMappings(row.LotMappings)
row.ResolvedLotName = ""
row.ResolutionSource = ""
row.ManualLotSuggestion = ""
row.LotQtyPerPN = 0
row.LotAllocations = nil
canonical = append(canonical, row)
}
estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate")
var serverPricelistID *uint
if estimatePricelist != nil {
serverPricelistID = &estimatePricelist.ServerID
}
items := aggregateVendorSpecToItems(canonical, estimatePricelist, s.localDB)
totalValue := items.Total()
if serverCount > 1 {
totalValue *= float64(serverCount)
}
totalPrice := &totalValue
return canonical, items, totalPrice, serverPricelistID, nil
}
func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *localdb.LocalPricelist, local *localdb.LocalDB) localdb.LocalConfigItems {
if len(spec) == 0 {
return localdb.LocalConfigItems{}
}
lotMap := make(map[string]int)
order := make([]string, 0)
for _, row := range spec {
for _, mapping := range normalizeImportedLotMappings(row.LotMappings) {
if _, exists := lotMap[mapping.LotName]; !exists {
order = append(order, mapping.LotName)
}
lotMap[mapping.LotName] += row.Quantity * mapping.QuantityPerPN
}
}
sort.Strings(order)
var priceMap map[string]float64
if estimatePricelist != nil && local != nil && len(order) > 0 {
priceMap, _ = local.GetLocalPricesForLots(estimatePricelist.ID, order)
}
items := make(localdb.LocalConfigItems, 0, len(order))
for _, lotName := range order {
unitPrice := 0.0
if priceMap != nil {
unitPrice = priceMap[lotName]
}
items = append(items, localdb.LocalConfigItem{
LotName: lotName,
Quantity: lotMap[lotName],
UnitPrice: unitPrice,
})
}
return items
}
func normalizeImportedLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(in) == 0 {
return nil
}
merged := make(map[string]int, len(in))
order := make([]string, 0, len(in))
for _, mapping := range in {
lot := strings.TrimSpace(mapping.LotName)
if lot == "" {
continue
}
qty := mapping.QuantityPerPN
if qty < 1 {
qty = 1
}
if _, exists := merged[lot]; !exists {
order = append(order, lot)
}
merged[lot] += qty
}
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
for _, lot := range order {
out = append(out, localdb.VendorSpecLotMapping{
LotName: lot,
QuantityPerPN: merged[lot],
})
}
if len(out) == 0 {
return nil
}
return out
}
func parseCFXMLWorkspace(data []byte, sourceFileName string) (*importedWorkspace, error) {
var doc cfxmlDocument
if err := xml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("parse CFXML workspace: %w", err)
}
if doc.XMLName.Local != "CFXML" {
return nil, fmt.Errorf("unsupported workspace root: %s", doc.XMLName.Local)
}
if len(doc.CFData.ProductLineItems) == 0 {
return nil, fmt.Errorf("CFXML workspace has no ProductLineItem rows")
}
workspace := &importedWorkspace{
SourceFormat: "CFXML",
SourceDocID: strings.TrimSpace(doc.ThisDocumentIdentifier.ProprietaryDocumentIdentifier),
SourceFileName: sourceFileName,
CurrencyCode: detectWorkspaceCurrency(doc.CFData.ProprietaryInformation, doc.CFData.ProductLineItems),
}
type groupBucket struct {
order int
items []groupedItem
}
groupOrder := make([]string, 0)
groups := make(map[string]*groupBucket)
for idx, item := range doc.CFData.ProductLineItems {
groupID := strings.TrimSpace(item.ProprietaryGroupIdentifier)
if groupID == "" {
groupID = firstNonEmpty(strings.TrimSpace(item.ProductLineNumber), strings.TrimSpace(item.ItemNo), fmt.Sprintf("group-%d", idx+1))
}
bucket := groups[groupID]
if bucket == nil {
bucket = &groupBucket{order: idx}
groups[groupID] = bucket
groupOrder = append(groupOrder, groupID)
}
bucket.items = append(bucket.items, groupedItem{order: idx, row: item})
}
for lineIdx, groupID := range groupOrder {
bucket := groups[groupID]
if bucket == nil || len(bucket.items) == 0 {
continue
}
primary := pickPrimaryTopLevelRow(bucket.items)
serverCount := maxInt(parseInt(primary.row.Quantity), 1)
rows := make([]localdb.VendorSpecItem, 0, len(bucket.items)*4)
sortOrder := 10
for _, item := range bucket.items {
topRow := vendorSpecItemFromTopLevel(item.row, serverCount, sortOrder)
if topRow != nil {
rows = append(rows, *topRow)
sortOrder += 10
}
for _, sub := range item.row.ProductSubLineItems {
subRow := vendorSpecItemFromSubLine(sub, sortOrder)
if subRow == nil {
continue
}
rows = append(rows, *subRow)
sortOrder += 10
}
}
total := sumVendorSpecRows(rows, serverCount)
name := strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductName)
if name == "" {
name = strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription)
}
if name == "" {
name = fmt.Sprintf("Imported config %d", lineIdx+1)
}
workspace.Configurations = append(workspace.Configurations, importedConfiguration{
GroupID: groupID,
Name: name,
Line: (lineIdx + 1) * 10,
ServerCount: serverCount,
ServerModel: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription),
Article: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier),
CurrencyCode: workspace.CurrencyCode,
Rows: rows,
TotalPrice: total,
})
}
if len(workspace.Configurations) == 0 {
return nil, fmt.Errorf("CFXML workspace has no importable configuration groups")
}
return workspace, nil
}
func detectWorkspaceCurrency(meta []cfxmlProprietaryInformation, rows []cfxmlProductLineItem) string {
for _, item := range meta {
if strings.EqualFold(strings.TrimSpace(item.Name), "Currencies") {
value := strings.TrimSpace(item.Value)
if value != "" {
return value
}
}
}
for _, row := range rows {
code := strings.TrimSpace(row.UnitListPrice.FinancialAmount.GlobalCurrencyCode)
if code != "" {
return code
}
}
return ""
}
func pickPrimaryTopLevelRow(items []groupedItem) groupedItem {
best := items[0]
bestScore := primaryScore(best.row)
for _, item := range items[1:] {
score := primaryScore(item.row)
if score > bestScore {
best = item
bestScore = score
continue
}
if score == bestScore && compareLineNumbers(item.row.ProductLineNumber, best.row.ProductLineNumber) < 0 {
best = item
}
}
return best
}
func primaryScore(row cfxmlProductLineItem) int {
score := len(row.ProductSubLineItems)
if strings.EqualFold(strings.TrimSpace(row.ProductIdentification.PartnerProductIdentification.ProductTypeCode), "Hardware") {
score += 100000
}
return score
}
func compareLineNumbers(left, right string) int {
li := parseInt(left)
ri := parseInt(right)
switch {
case li < ri:
return -1
case li > ri:
return 1
default:
return strings.Compare(left, right)
}
}
func vendorSpecItemFromTopLevel(item cfxmlProductLineItem, serverCount int, sortOrder int) *localdb.VendorSpecItem {
code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
if code == "" && desc == "" {
return nil
}
qty := normalizeTopLevelQuantity(item.Quantity, serverCount)
unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
return &localdb.VendorSpecItem{
SortOrder: sortOrder,
VendorPartnumber: code,
Quantity: qty,
Description: desc,
UnitPrice: unitPrice,
TotalPrice: totalPrice(unitPrice, qty),
}
}
func vendorSpecItemFromSubLine(item cfxmlProductSubLineItem, sortOrder int) *localdb.VendorSpecItem {
code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
if code == "" && desc == "" {
return nil
}
qty := maxInt(parseInt(item.Quantity), 1)
unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
return &localdb.VendorSpecItem{
SortOrder: sortOrder,
VendorPartnumber: code,
Quantity: qty,
Description: desc,
UnitPrice: unitPrice,
TotalPrice: totalPrice(unitPrice, qty),
}
}
func sumVendorSpecRows(rows []localdb.VendorSpecItem, serverCount int) *float64 {
total := 0.0
hasTotal := false
for _, row := range rows {
if row.TotalPrice == nil {
continue
}
total += *row.TotalPrice
hasTotal = true
}
if !hasTotal {
return nil
}
if serverCount > 1 {
total *= float64(serverCount)
}
return &total
}
func totalPrice(unitPrice *float64, qty int) *float64 {
if unitPrice == nil {
return nil
}
total := *unitPrice * float64(qty)
return &total
}
func parseOptionalFloat(raw string) *float64 {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}
value, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return nil
}
return &value
}
func parseInt(raw string) int {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return 0
}
value, err := strconv.Atoi(trimmed)
if err != nil {
return 0
}
return value
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func maxInt(value, floor int) int {
if value < floor {
return floor
}
return value
}
func normalizeTopLevelQuantity(raw string, serverCount int) int {
qty := maxInt(parseInt(raw), 1)
if serverCount <= 1 {
return qty
}
if qty%serverCount == 0 {
return maxInt(qty/serverCount, 1)
}
return qty
}
func IsCFXMLWorkspace(data []byte) bool {
return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
}
func IsInspurBOM(data []byte) bool {
for _, line := range bytes.Split(data, []byte("\n")) {
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 {
continue
}
idx := bytes.LastIndexByte(trimmed, '*')
if idx <= 0 {
continue
}
suffix := bytes.TrimSpace(trimmed[idx+1:])
if len(suffix) > 0 && allDigits(suffix) {
return true
}
}
return false
}
func allDigits(b []byte) bool {
if len(b) == 0 {
return false
}
for _, c := range b {
if c < '0' || c > '9' {
return false
}
}
return true
}
func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
lines := strings.Split(string(data), "\n")
rows := make([]localdb.VendorSpecItem, 0, len(lines))
sortOrder := 10
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
line = strings.TrimPrefix(line, "|")
line = strings.TrimSpace(line)
if line == "" {
continue
}
pn := line
qty := 1
if idx := strings.LastIndex(line, "*"); idx > 0 {
suffix := strings.TrimSpace(line[idx+1:])
if n, err := strconv.Atoi(suffix); err == nil && n > 0 {
pn = strings.TrimSpace(line[:idx])
qty = n
}
}
if pn == "" {
continue
}
rows = append(rows, localdb.VendorSpecItem{
SortOrder: sortOrder,
VendorPartnumber: pn,
Quantity: qty,
})
sortOrder += 10
}
if len(rows) == 0 {
return nil, fmt.Errorf("Inspur BOM has no importable rows")
}
name := strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
if name == "" {
name = "Inspur Import"
}
return &importedWorkspace{
SourceFormat: "Inspur",
SourceFileName: sourceFileName,
Configurations: []importedConfiguration{
{
GroupID: "inspur-0",
Name: name,
Line: 10,
ServerCount: 1,
Rows: rows,
},
},
}, nil
}
// textBOMItemLine matches a human-readable BOM line of the form
// "<description> - <quantity> шт." where the separator may be a hyphen,
// en-dash or em-dash and the quantity may have an optional space before "шт".
// The quantity anchor at the end keeps internal hyphens/digits in the
// description (e.g. "8-GPU-2304GB") from being mistaken for the separator.
var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`)
// textBOMHeaderLine matches the configuration header "Сервер <model>, в составе:".
var textBOMHeaderLine = regexp.MustCompile(`(?i)^\s*сервер\s+(.+?)\s*,\s*в\s+составе`)
// IsTextBOM reports whether data looks like a human-readable Russian text BOM,
// i.e. it contains at least one "<description> - <quantity> шт." line.
func IsTextBOM(data []byte) bool {
for _, raw := range strings.Split(string(data), "\n") {
if textBOMItemLine.MatchString(strings.TrimSpace(raw)) {
return true
}
}
return false
}
// parseTextBOM parses a human-readable Russian text BOM into a single configuration.
// The optional "Сервер <model>, в составе:" header provides the configuration name and
// server model. Each "<description> - <quantity> шт." line becomes one vendor spec row.
// The format carries no partnumbers, so rows stay unresolved and editable in the UI
// until mapped through the active partnumber book.
func parseTextBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
lines := strings.Split(string(data), "\n")
rows := make([]localdb.VendorSpecItem, 0, len(lines))
sortOrder := 10
serverModel := ""
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
serverModel = strings.TrimSpace(m[1])
continue
}
m := textBOMItemLine.FindStringSubmatch(line)
if m == nil {
continue
}
description := strings.TrimSpace(m[1])
qty, err := strconv.Atoi(m[2])
if err != nil || qty <= 0 || description == "" {
continue
}
rows = append(rows, localdb.VendorSpecItem{
SortOrder: sortOrder,
VendorPartnumber: description,
Quantity: qty,
Description: description,
})
sortOrder += 10
}
if len(rows) == 0 {
return nil, fmt.Errorf("text BOM has no importable rows")
}
name := serverModel
if name == "" {
name = strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
}
if name == "" {
name = "Text BOM Import"
}
return &importedWorkspace{
SourceFormat: "Text",
SourceFileName: sourceFileName,
Configurations: []importedConfiguration{
{
GroupID: "text-0",
Name: name,
Line: 10,
ServerCount: 1,
ServerModel: serverModel,
Rows: rows,
},
},
}, 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
}