package services import ( "bytes" "encoding/xml" "fmt" "path/filepath" "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 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 } workspace, err := parseCFXMLWorkspace(data, filepath.Base(sourceFileName)) 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() 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) } 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) items := make(localdb.LocalConfigItems, 0, len(order)) for _, lotName := range order { unitPrice := 0.0 if estimatePricelist != nil && local != nil { if price, err := local.GetLocalPriceForLot(estimatePricelist.ID, lotName); err == nil && price > 0 { unitPrice = price } } 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("")) || bytes.Contains(data, []byte("