561 lines
16 KiB
Go
561 lines
16 KiB
Go
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("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
|
|
}
|