Add vendor workspace import and pricing export workflow

This commit is contained in:
Mikhail Chusavitin
2026-03-07 21:03:40 +03:00
parent 08ecfd0826
commit 7c3752f110
30 changed files with 3042 additions and 482 deletions

View File

@@ -45,18 +45,21 @@ func NewConfigurationService(
}
type CreateConfigRequest struct {
Name string `json:"name"`
Items models.ConfigItems `json:"items"`
ProjectUUID *string `json:"project_uuid,omitempty"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
ServerModel string `json:"server_model,omitempty"`
SupportCode string `json:"support_code,omitempty"`
Article string `json:"article,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
OnlyInStock bool `json:"only_in_stock"`
Name string `json:"name"`
Items models.ConfigItems `json:"items"`
ProjectUUID *string `json:"project_uuid,omitempty"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
ServerModel string `json:"server_model,omitempty"`
SupportCode string `json:"support_code,omitempty"`
Article string `json:"article,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"`
}
type ArticlePreviewRequest struct {
@@ -84,21 +87,24 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
}
config := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID,
WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID,
DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock,
}
if err := s.configRepo.Create(config); err != nil {
@@ -163,6 +169,9 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
config.SupportCode = req.SupportCode
config.Article = req.Article
config.PricelistID = pricelistID
config.WarehousePricelistID = req.WarehousePricelistID
config.CompetitorPricelistID = req.CompetitorPricelistID
config.DisablePriceRefresh = req.DisablePriceRefresh
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {
@@ -230,18 +239,24 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
}
clone := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
OnlyInStock: original.OnlyInStock,
}
if err := s.configRepo.Create(clone); err != nil {
@@ -314,7 +329,13 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.ServerModel = req.ServerModel
config.SupportCode = req.SupportCode
config.Article = req.Article
config.PricelistID = pricelistID
config.WarehousePricelistID = req.WarehousePricelistID
config.CompetitorPricelistID = req.CompetitorPricelistID
config.DisablePriceRefresh = req.DisablePriceRefresh
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {

View File

@@ -55,6 +55,38 @@ type ProjectExportData struct {
CreatedAt time.Time
}
type ProjectPricingExportOptions struct {
IncludeLOT bool `json:"include_lot"`
IncludeBOM bool `json:"include_bom"`
IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"`
IncludeCompetitor bool `json:"include_competitor"`
}
type ProjectPricingExportData struct {
Configs []ProjectPricingExportConfig
CreatedAt time.Time
}
type ProjectPricingExportConfig struct {
Name string
Article string
Line int
ServerCount int
Rows []ProjectPricingExportRow
}
type ProjectPricingExportRow struct {
LotDisplay string
VendorPN string
Description string
Quantity int
BOMTotal *float64
Estimate *float64
Stock *float64
Competitor *float64
}
// ToCSV writes project export data in the new structured CSV format.
//
// Format:
@@ -168,6 +200,80 @@ func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
return buf.Bytes(), nil
}
func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
sortedConfigs := make([]models.Configuration, len(configs))
copy(sortedConfigs, configs)
sort.Slice(sortedConfigs, func(i, j int) bool {
leftLine := sortedConfigs[i].Line
rightLine := sortedConfigs[j].Line
if leftLine <= 0 {
leftLine = int(^uint(0) >> 1)
}
if rightLine <= 0 {
rightLine = int(^uint(0) >> 1)
}
if leftLine != rightLine {
return leftLine < rightLine
}
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
}
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
})
blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs))
for i := range sortedConfigs {
block, err := s.buildPricingExportBlock(&sortedConfigs[i], opts)
if err != nil {
return nil, err
}
blocks = append(blocks, block)
}
return &ProjectPricingExportData{
Configs: blocks,
CreatedAt: time.Now(),
}, nil
}
func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData, opts ProjectPricingExportOptions) error {
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return fmt.Errorf("failed to write BOM: %w", err)
}
csvWriter := csv.NewWriter(w)
csvWriter.Comma = ';'
defer csvWriter.Flush()
headers := pricingCSVHeaders(opts)
if err := csvWriter.Write(headers); err != nil {
return fmt.Errorf("failed to write pricing header: %w", err)
}
for idx, cfg := range data.Configs {
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
return fmt.Errorf("failed to write config summary row: %w", err)
}
for _, row := range cfg.Rows {
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
return fmt.Errorf("failed to write pricing row: %w", err)
}
}
if idx < len(data.Configs)-1 {
if err := csvWriter.Write([]string{}); err != nil {
return fmt.Errorf("failed to write separator row: %w", err)
}
}
}
csvWriter.Flush()
if err := csvWriter.Error(); err != nil {
return fmt.Errorf("csv writer error: %w", err)
}
return nil
}
// ConfigToExportData converts a single configuration into ProjectExportData.
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
block := s.buildExportBlock(cfg)
@@ -247,6 +353,99 @@ func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExport
}
}
func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts ProjectPricingExportOptions) (ProjectPricingExportConfig, error) {
block := ProjectPricingExportConfig{
Name: cfg.Name,
Article: cfg.Article,
Line: cfg.Line,
ServerCount: exportPositiveInt(cfg.ServerCount, 1),
Rows: make([]ProjectPricingExportRow, 0),
}
if s.localDB == nil {
for _, item := range cfg.Items {
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName,
VendorPN: "—",
Quantity: item.Quantity,
Estimate: floatPtr(item.UnitPrice * float64(item.Quantity)),
})
}
return block, nil
}
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
if err != nil {
localCfg = nil
}
priceMap := s.resolvePricingTotals(cfg, localCfg, opts)
componentDescriptions := s.resolveLotDescriptions(cfg, localCfg)
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
coveredLots := make(map[string]struct{})
for _, row := range localCfg.VendorSpec {
rowMappings := normalizeLotMappings(row.LotMappings)
for _, mapping := range rowMappings {
coveredLots[mapping.LotName] = struct{}{}
}
description := strings.TrimSpace(row.Description)
if description == "" && len(rowMappings) > 0 {
description = componentDescriptions[rowMappings[0].LotName]
}
pricingRow := ProjectPricingExportRow{
LotDisplay: formatLotDisplay(rowMappings),
VendorPN: row.VendorPartnumber,
Description: description,
Quantity: exportPositiveInt(row.Quantity, 1),
BOMTotal: vendorRowTotal(row),
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
}
block.Rows = append(block.Rows, pricingRow)
}
for _, item := range cfg.Items {
if item.LotName == "" {
continue
}
if _, ok := coveredLots[item.LotName]; ok {
continue
}
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName,
VendorPN: "—",
Description: componentDescriptions[item.LotName],
Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate,
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
})
}
return block, nil
}
for _, item := range cfg.Items {
if item.LotName == "" {
continue
}
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName,
VendorPN: "—",
Description: componentDescriptions[item.LotName],
Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate,
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
})
}
return block, nil
}
// resolveCategories returns lot_name → category map.
// Primary source: pricelist items (lot_category). Fallback: local_components table.
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
@@ -303,6 +502,324 @@ func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
}
}
type pricingLevels struct {
Estimate *float64
Stock *float64
Competitor *float64
}
func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, opts ProjectPricingExportOptions) map[string]pricingLevels {
result := map[string]pricingLevels{}
lots := collectPricingLots(cfg, localCfg, opts.IncludeBOM)
if len(lots) == 0 || s.localDB == nil {
return result
}
estimateID := cfg.PricelistID
if estimateID == nil || *estimateID == 0 {
if latest, err := s.localDB.GetLatestLocalPricelistBySource("estimate"); err == nil && latest != nil {
estimateID = &latest.ServerID
}
}
var warehouseID *uint
var competitorID *uint
if localCfg != nil {
warehouseID = localCfg.WarehousePricelistID
competitorID = localCfg.CompetitorPricelistID
}
if warehouseID == nil || *warehouseID == 0 {
if latest, err := s.localDB.GetLatestLocalPricelistBySource("warehouse"); err == nil && latest != nil {
warehouseID = &latest.ServerID
}
}
if competitorID == nil || *competitorID == 0 {
if latest, err := s.localDB.GetLatestLocalPricelistBySource("competitor"); err == nil && latest != nil {
competitorID = &latest.ServerID
}
}
for _, lot := range lots {
level := pricingLevels{}
level.Estimate = s.lookupPricePointer(estimateID, lot)
level.Stock = s.lookupPricePointer(warehouseID, lot)
level.Competitor = s.lookupPricePointer(competitorID, lot)
result[lot] = level
}
return result
}
func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 {
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" {
return nil
}
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
if err != nil {
return nil
}
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
if err != nil || price <= 0 {
return nil
}
return floatPtr(price)
}
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
lots := collectPricingLots(cfg, localCfg, true)
result := make(map[string]string, len(lots))
if s.localDB == nil {
return result
}
for _, lot := range lots {
component, err := s.localDB.GetLocalComponent(lot)
if err != nil {
continue
}
result[lot] = component.LotDescription
}
return result
}
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
seen := map[string]struct{}{}
out := make([]string, 0)
if includeBOM && localCfg != nil {
for _, row := range localCfg.VendorSpec {
for _, mapping := range normalizeLotMappings(row.LotMappings) {
if _, ok := seen[mapping.LotName]; ok {
continue
}
seen[mapping.LotName] = struct{}{}
out = append(out, mapping.LotName)
}
}
}
for _, item := range cfg.Items {
lot := strings.TrimSpace(item.LotName)
if lot == "" {
continue
}
if _, ok := seen[lot]; ok {
continue
}
seen[lot] = struct{}{}
out = append(out, lot)
}
return out
}
func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(mappings) == 0 {
return nil
}
out := make([]localdb.VendorSpecLotMapping, 0, len(mappings))
for _, mapping := range mappings {
lot := strings.TrimSpace(mapping.LotName)
if lot == "" {
continue
}
qty := mapping.QuantityPerPN
if qty < 1 {
qty = 1
}
out = append(out, localdb.VendorSpecLotMapping{
LotName: lot,
QuantityPerPN: qty,
})
}
return out
}
func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
if row.TotalPrice != nil {
return floatPtr(*row.TotalPrice)
}
if row.UnitPrice == nil {
return nil
}
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
}
func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 {
if len(mappings) == 0 {
return nil
}
total := 0.0
hasValue := false
qty := exportPositiveInt(pnQty, 1)
for _, mapping := range mappings {
price := selector(priceMap[mapping.LotName])
if price == nil || *price <= 0 {
continue
}
total += *price * float64(qty*mapping.QuantityPerPN)
hasValue = true
}
if !hasValue {
return nil
}
return floatPtr(total)
}
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
if unitPrice == nil || *unitPrice <= 0 {
return nil
}
total := *unitPrice * float64(exportPositiveInt(quantity, 1))
return &total
}
func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quantity int) *float64 {
if estimatePrice != nil && *estimatePrice > 0 {
return totalForUnitPrice(estimatePrice, quantity)
}
if fallbackUnitPrice <= 0 {
return nil
}
total := fallbackUnitPrice * float64(maxInt(quantity, 1))
return &total
}
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
headers := make([]string, 0, 8)
headers = append(headers, "Line Item")
if opts.IncludeLOT {
headers = append(headers, "LOT")
}
headers = append(headers, "PN вендора", "Описание", "Кол-во")
if opts.IncludeBOM {
headers = append(headers, "BOM")
}
if opts.IncludeEstimate {
headers = append(headers, "Estimate")
}
if opts.IncludeStock {
headers = append(headers, "Stock")
}
if opts.IncludeCompetitor {
headers = append(headers, "Конкуренты")
}
return headers
}
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 8)
record = append(record, "")
if opts.IncludeLOT {
record = append(record, emptyDash(row.LotDisplay))
}
record = append(record,
emptyDash(row.VendorPN),
emptyDash(row.Description),
fmt.Sprintf("%d", exportPositiveInt(row.Quantity, 1)),
)
if opts.IncludeBOM {
record = append(record, formatMoneyValue(row.BOMTotal))
}
if opts.IncludeEstimate {
record = append(record, formatMoneyValue(row.Estimate))
}
if opts.IncludeStock {
record = append(record, formatMoneyValue(row.Stock))
}
if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(row.Competitor))
}
return record
}
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 8)
record = append(record, fmt.Sprintf("%d", cfg.Line))
if opts.IncludeLOT {
record = append(record, "")
}
record = append(record,
"",
emptyDash(cfg.Name),
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
)
if opts.IncludeBOM {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.BOMTotal })))
}
if opts.IncludeEstimate {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Estimate })))
}
if opts.IncludeStock {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Stock })))
}
if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
}
return record
}
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
switch len(mappings) {
case 0:
return "н/д"
case 1:
return mappings[0].LotName
default:
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
}
}
func formatMoneyValue(value *float64) string {
if value == nil {
return "—"
}
n := math.Round(*value*100) / 100
sign := ""
if n < 0 {
sign = "-"
n = -n
}
whole := int64(n)
fraction := int(math.Round((n - float64(whole)) * 100))
if fraction == 100 {
whole++
fraction = 0
}
return fmt.Sprintf("%s%s,%02d", sign, formatIntWithSpace(whole), fraction)
}
func emptyDash(value string) string {
if strings.TrimSpace(value) == "" {
return "—"
}
return value
}
func sumPricingColumn(rows []ProjectPricingExportRow, selector func(ProjectPricingExportRow) *float64) *float64 {
total := 0.0
hasValue := false
for _, row := range rows {
value := selector(row)
if value == nil {
continue
}
total += *value
hasValue = true
}
if !hasValue {
return nil
}
return floatPtr(total)
}
func floatPtr(value float64) *float64 {
v := value
return &v
}
func exportPositiveInt(value, fallback int) int {
if value < 1 {
return fallback
}
return value
}
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
func formatPriceComma(value float64) string {

View File

@@ -444,6 +444,117 @@ func TestFormatPriceComma(t *testing.T) {
}
}
func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
data := &ProjectPricingExportData{
Configs: []ProjectPricingExportConfig{
{
Name: "Config A",
Article: "ART-1",
Line: 10,
ServerCount: 2,
Rows: []ProjectPricingExportRow{
{
LotDisplay: "LOT_A +1",
VendorPN: "PN-001",
Description: "Bundle row",
Quantity: 2,
BOMTotal: floatPtr(2400.5),
Estimate: floatPtr(2000),
Stock: floatPtr(1800.25),
},
},
},
},
CreatedAt: time.Now(),
}
opts := ProjectPricingExportOptions{
IncludeLOT: true,
IncludeBOM: true,
IncludeEstimate: true,
IncludeStock: true,
}
var buf bytes.Buffer
if err := svc.ToPricingCSV(&buf, data, opts); err != nil {
t.Fatalf("ToPricingCSV failed: %v", err)
}
reader := csv.NewReader(bytes.NewReader(buf.Bytes()[3:]))
reader.Comma = ';'
reader.FieldsPerRecord = -1
header, err := reader.Read()
if err != nil {
t.Fatalf("read header row: %v", err)
}
expectedHeader := []string{"Line Item", "LOT", "PN вендора", "Описание", "Кол-во", "BOM", "Estimate", "Stock"}
for i, want := range expectedHeader {
if header[i] != want {
t.Fatalf("header[%d]: expected %q, got %q", i, want, header[i])
}
}
summary, err := reader.Read()
if err != nil {
t.Fatalf("read summary row: %v", err)
}
expectedSummary := []string{"10", "", "", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
for i, want := range expectedSummary {
if summary[i] != want {
t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i])
}
}
row, err := reader.Read()
if err != nil {
t.Fatalf("read data row: %v", err)
}
expectedRow := []string{"", "LOT_A +1", "PN-001", "Bundle row", "2", "2 400,50", "2 000,00", "1 800,25"}
for i, want := range expectedRow {
if row[i] != want {
t.Fatalf("row[%d]: expected %q, got %q", i, want, row[i])
}
}
}
func TestProjectToPricingExportData_UsesCartRowsWithoutBOM(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
configs := []models.Configuration{
{
UUID: "cfg-1",
Name: "Config A",
Article: "ART-1",
ServerCount: 1,
Items: models.ConfigItems{
{LotName: "LOT_A", Quantity: 2, UnitPrice: 300},
},
CreatedAt: time.Now(),
},
}
data, err := svc.ProjectToPricingExportData(configs, ProjectPricingExportOptions{
IncludeLOT: true,
IncludeEstimate: true,
})
if err != nil {
t.Fatalf("ProjectToPricingExportData failed: %v", err)
}
if len(data.Configs) != 1 || len(data.Configs[0].Rows) != 1 {
t.Fatalf("unexpected rows count: %+v", data.Configs)
}
row := data.Configs[0].Rows[0]
if row.LotDisplay != "LOT_A" {
t.Fatalf("expected LOT_A, got %q", row.LotDisplay)
}
if row.VendorPN != "—" {
t.Fatalf("expected vendor dash, got %q", row.VendorPN)
}
if row.Estimate == nil || *row.Estimate != 600 {
t.Fatalf("expected estimate total 600, got %+v", row.Estimate)
}
}
// failingWriter always returns an error
type failingWriter struct{}

View File

@@ -83,22 +83,25 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
}
cfg := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(),
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID,
WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID,
DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(),
}
// Convert to local model
@@ -196,6 +199,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
localCfg.SupportCode = req.SupportCode
localCfg.Article = req.Article
localCfg.PricelistID = pricelistID
localCfg.WarehousePricelistID = req.WarehousePricelistID
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
@@ -304,22 +310,25 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
}
clone := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
}
localCfg := localdb.ConfigurationToLocal(clone)
@@ -521,6 +530,9 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
localCfg.SupportCode = req.SupportCode
localCfg.Article = req.Article
localCfg.PricelistID = pricelistID
localCfg.WarehousePricelistID = req.WarehousePricelistID
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
@@ -623,19 +635,25 @@ func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID s
}
clone := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
}
localCfg := localdb.ConfigurationToLocal(clone)
@@ -1053,39 +1071,43 @@ func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, own
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
if localCfg.IsActive {
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
return err
}
}
if err := tx.Create(localCfg).Error; err != nil {
return fmt.Errorf("create local configuration: %w", err)
}
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
if err != nil {
return fmt.Errorf("append create version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
localCfg.CurrentVersionID = &version.ID
localCfg.CurrentVersion = version
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
return fmt.Errorf("enqueue create pending change: %w", err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
return s.createWithVersionTx(tx, localCfg, createdBy)
})
}
func (s *LocalConfigurationService) createWithVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration, createdBy string) error {
if localCfg.IsActive {
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
return err
}
}
if err := tx.Create(localCfg).Error; err != nil {
return fmt.Errorf("create local configuration: %w", err)
}
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
if err != nil {
return fmt.Errorf("append create version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
localCfg.CurrentVersionID = &version.ID
localCfg.CurrentVersion = version
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
return fmt.Errorf("enqueue create pending change: %w", err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
}
func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.LocalConfiguration, operation string, createdBy string) (*models.Configuration, error) {
var cfg *models.Configuration
@@ -1183,6 +1205,7 @@ func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, nex
current.ServerModel != next.ServerModel ||
current.SupportCode != next.SupportCode ||
current.Article != next.Article ||
current.DisablePriceRefresh != next.DisablePriceRefresh ||
current.OnlyInStock != next.OnlyInStock ||
current.IsActive != next.IsActive ||
current.Line != next.Line {
@@ -1419,8 +1442,15 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
current.Notes = rollbackData.Notes
current.IsTemplate = rollbackData.IsTemplate
current.ServerCount = rollbackData.ServerCount
current.ServerModel = rollbackData.ServerModel
current.SupportCode = rollbackData.SupportCode
current.Article = rollbackData.Article
current.PricelistID = rollbackData.PricelistID
current.WarehousePricelistID = rollbackData.WarehousePricelistID
current.CompetitorPricelistID = rollbackData.CompetitorPricelistID
current.DisablePriceRefresh = rollbackData.DisablePriceRefresh
current.OnlyInStock = rollbackData.OnlyInStock
current.VendorSpec = rollbackData.VendorSpec
if rollbackData.Line > 0 {
current.Line = rollbackData.Line
}

View File

@@ -14,7 +14,7 @@ type SeenPartnumber struct {
}
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
// Uses INSERT ... ON DUPLICATE KEY UPDATE so existing rows are updated (last_seen_at) without error.
// Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
if len(items) == 0 {
return nil
@@ -36,12 +36,10 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
VALUES
('manual', '', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_seen_at = VALUES(last_seen_at),
is_ignored = VALUES(is_ignored),
description = COALESCE(NULLIF(VALUES(description), ''), description)
partnumber = partnumber
`, item.Partnumber, item.Description, item.Ignored, now).Error
if err != nil {
slog.Error("failed to upsert partnumber_seen", "partnumber", item.Partnumber, "error", err)
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
// Continue with remaining items
}
}

View File

@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
@@ -213,17 +214,64 @@ func ensureClientMigrationRegistryTable(db *gorm.DB) error {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
username VARCHAR(100) NOT NULL,
hostname VARCHAR(255) NOT NULL DEFAULT '',
last_applied_migration_id VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
last_sync_at DATETIME NULL,
last_sync_status VARCHAR(32) NULL,
pending_changes_count INT NOT NULL DEFAULT 0,
pending_errors_count INT NOT NULL DEFAULT 0,
configurations_count INT NOT NULL DEFAULT 0,
projects_count INT NOT NULL DEFAULT 0,
estimate_pricelist_version VARCHAR(128) NULL,
warehouse_pricelist_version VARCHAR(128) NULL,
competitor_pricelist_version VARCHAR(128) NULL,
last_sync_error_code VARCHAR(128) NULL,
last_sync_error_text TEXT NULL,
last_checked_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (username),
PRIMARY KEY (username, hostname),
INDEX idx_qt_client_schema_state_checked (last_checked_at)
)
`).Error; err != nil {
return fmt.Errorf("create qt_client_schema_state table: %w", err)
}
}
if tableExists(db, "qt_client_schema_state") {
if err := db.Exec(`
ALTER TABLE qt_client_schema_state
ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER username
`).Error; err != nil {
return fmt.Errorf("add qt_client_schema_state.hostname: %w", err)
}
if err := db.Exec(`
ALTER TABLE qt_client_schema_state
DROP PRIMARY KEY,
ADD PRIMARY KEY (username, hostname)
`).Error; err != nil && !isDuplicatePrimaryKeyDefinition(err) {
return fmt.Errorf("set qt_client_schema_state primary key: %w", err)
}
for _, stmt := range []string{
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_status VARCHAR(32) NULL AFTER last_sync_at",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_errors_count INT NOT NULL DEFAULT 0 AFTER pending_changes_count",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS warehouse_pricelist_version VARCHAR(128) NULL AFTER estimate_pricelist_version",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
} {
if err := db.Exec(stmt).Error; err != nil {
return fmt.Errorf("expand qt_client_schema_state: %w", err)
}
}
}
return nil
}
@@ -351,19 +399,108 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
if username == "" {
return nil
}
hostname, err := os.Hostname()
if err != nil {
hostname = ""
}
hostname = strings.TrimSpace(hostname)
lastMigrationID := ""
if id, err := s.localDB.GetLatestAppliedRemoteMigrationID(); err == nil {
lastMigrationID = id
}
lastSyncAt := s.localDB.GetLastSyncTime()
lastSyncStatus := ReadinessReady
pendingChangesCount := s.localDB.CountPendingChanges()
pendingErrorsCount := s.localDB.CountErroredChanges()
configurationsCount := s.localDB.CountConfigurations()
projectsCount := s.localDB.CountProjects()
estimateVersion := latestPricelistVersion(s.localDB, "estimate")
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
return mariaDB.Exec(`
INSERT INTO qt_client_schema_state (username, last_applied_migration_id, app_version, last_checked_at, updated_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO qt_client_schema_state (
username, hostname, last_applied_migration_id, app_version,
last_sync_at, last_sync_status, pending_changes_count, pending_errors_count,
configurations_count, projects_count,
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
last_sync_error_code, last_sync_error_text,
last_checked_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_applied_migration_id = VALUES(last_applied_migration_id),
app_version = VALUES(app_version),
last_sync_at = VALUES(last_sync_at),
last_sync_status = VALUES(last_sync_status),
pending_changes_count = VALUES(pending_changes_count),
pending_errors_count = VALUES(pending_errors_count),
configurations_count = VALUES(configurations_count),
projects_count = VALUES(projects_count),
estimate_pricelist_version = VALUES(estimate_pricelist_version),
warehouse_pricelist_version = VALUES(warehouse_pricelist_version),
competitor_pricelist_version = VALUES(competitor_pricelist_version),
last_sync_error_code = VALUES(last_sync_error_code),
last_sync_error_text = VALUES(last_sync_error_text),
last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at)
`, username, lastMigrationID, appmeta.Version(), checkedAt, checkedAt).Error
`, username, hostname, lastMigrationID, appmeta.Version(),
lastSyncAt, lastSyncStatus, pendingChangesCount, pendingErrorsCount,
configurationsCount, projectsCount,
estimateVersion, warehouseVersion, competitorVersion,
lastSyncErrorCode, lastSyncErrorText,
checkedAt, checkedAt).Error
}
func isDuplicatePrimaryKeyDefinition(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "multiple primary key defined") ||
strings.Contains(msg, "duplicate key name 'primary'") ||
strings.Contains(msg, "duplicate entry")
}
func latestPricelistVersion(local *localdb.LocalDB, source string) *string {
if local == nil {
return nil
}
pl, err := local.GetLatestLocalPricelistBySource(source)
if err != nil || pl == nil {
return nil
}
version := strings.TrimSpace(pl.Version)
if version == "" {
return nil
}
return &version
}
func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
if local == nil {
return nil, nil
}
if guard, err := local.GetSyncGuardState(); err == nil && guard != nil && strings.EqualFold(guard.Status, ReadinessBlocked) {
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
}
var pending localdb.PendingChange
if err := local.DB().
Where("TRIM(COALESCE(last_error, '')) <> ''").
Order("id DESC").
First(&pending).Error; err == nil {
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
}
return nil, nil
}
func optionalString(value string) *string {
if strings.TrimSpace(value) == "" {
return nil
}
v := strings.TrimSpace(value)
return &v
}
func normalizeVersionParts(v string) []int {

View File

@@ -148,9 +148,6 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
if localCfg.Line <= 0 && existing.Line > 0 {
localCfg.Line = existing.Line
}
// vendor_spec is local-only for BOM tab and is not stored on server.
// Preserve it during server pull updates.
localCfg.VendorSpec = existing.VendorSpec
result.Updated++
} else {
result.Imported++

View File

@@ -315,10 +315,21 @@ func TestImportConfigurationsToLocalPullsLine(t *testing.T) {
}
}
func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
func TestImportConfigurationsToLocalLoadsVendorSpecFromServer(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
serverSpec := models.VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "GPU-NVHGX-H200-8141",
Quantity: 1,
Description: "NVIDIA HGX Delta-Next GPU Baseboard",
LotMappings: []models.VendorSpecLotMapping{
{LotName: "GPU_NV_H200_141GB_SXM_(HGX)", QuantityPerPN: 8},
},
},
}
cfg := models.Configuration{
UUID: "server-vendorspec-config",
OwnerUsername: "tester",
@@ -326,6 +337,7 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
ServerCount: 1,
Line: 50,
VendorSpec: serverSpec,
}
total := cfg.Items.Total()
cfg.TotalPrice = &total
@@ -333,32 +345,6 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
t.Fatalf("seed server config: %v", err)
}
localSpec := localdb.VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "GPU-NVHGX-H200-8141",
Quantity: 1,
Description: "NVIDIA HGX Delta-Next GPU Baseboard",
LotMappings: []localdb.VendorSpecLotMapping{
{LotName: "GPU_NV_H200_141GB_SXM_(HGX)", QuantityPerPN: 8},
},
},
}
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
UUID: cfg.UUID,
OriginalUsername: "tester",
Name: "Local cfg",
Items: localdb.LocalConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
IsActive: true,
SyncStatus: "synced",
Line: 50,
VendorSpec: localSpec,
CreatedAt: time.Now().Add(-30 * time.Minute),
UpdatedAt: time.Now().Add(-30 * time.Minute),
}); err != nil {
t.Fatalf("seed local configuration: %v", err)
}
svc := syncsvc.NewServiceWithDB(serverDB, local)
if _, err := svc.ImportConfigurationsToLocal(); err != nil {
t.Fatalf("import configurations to local: %v", err)
@@ -369,7 +355,7 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
t.Fatalf("load local config: %v", err)
}
if len(localCfg.VendorSpec) != 1 {
t.Fatalf("expected local vendor_spec preserved, got %d rows", len(localCfg.VendorSpec))
t.Fatalf("expected server vendor_spec imported, got %d rows", len(localCfg.VendorSpec))
}
if localCfg.VendorSpec[0].VendorPartnumber != "GPU-NVHGX-H200-8141" {
t.Fatalf("unexpected vendor_partnumber after import: %q", localCfg.VendorSpec[0].VendorPartnumber)
@@ -492,6 +478,7 @@ CREATE TABLE qt_configurations (
only_in_stock INTEGER NOT NULL DEFAULT 0,
line_no INTEGER NULL,
price_updated_at DATETIME NULL,
vendor_spec TEXT NULL,
created_at DATETIME
);`).Error; err != nil {
t.Fatalf("create qt_configurations: %v", err)

View File

@@ -0,0 +1,560 @@
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 "))
}

View File

@@ -0,0 +1,363 @@
package services
import (
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
)
func TestParseCFXMLWorkspaceGroupsSoftwareIntoConfiguration(t *testing.T) {
const sample = `<?xml version="1.0" encoding="UTF-8"?>
<CFXML>
<thisDocumentIdentifier>
<ProprietaryDocumentIdentifier>CFXML.workspace-test</ProprietaryDocumentIdentifier>
</thisDocumentIdentifier>
<CFData>
<ProprietaryInformation>
<Name>Currencies</Name>
<Value>USD</Value>
</ProprietaryInformation>
<ProductLineItem>
<ProductLineNumber>1000</ProductLineNumber>
<ItemNo>1000</ItemNo>
<TransactionType>NEW</TransactionType>
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
<Quantity>6</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
<ProductName>#1</ProductName>
<ProductTypeCode>Hardware</ProductTypeCode>
</PartnerProductIdentification>
</ProductIdentification>
<UnitListPrice>
<FinancialAmount>
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
<MonetaryAmount>100.00</MonetaryAmount>
</FinancialAmount>
</UnitListPrice>
<ProductSubLineItem>
<LineNumber>1001</LineNumber>
<TransactionType>ADD</TransactionType>
<Quantity>2</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>CPU-1</ProprietaryProductIdentifier>
<ProductDescription>CPU</ProductDescription>
<ProductCharacter>PROCESSOR</ProductCharacter>
</PartnerProductIdentification>
</ProductIdentification>
<UnitListPrice>
<FinancialAmount>
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
<MonetaryAmount>0</MonetaryAmount>
</FinancialAmount>
</UnitListPrice>
</ProductSubLineItem>
</ProductLineItem>
<ProductLineItem>
<ProductLineNumber>2000</ProductLineNumber>
<ItemNo>2000</ItemNo>
<TransactionType>NEW</TransactionType>
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
<Quantity>6</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>7S0X-CTO8WW</ProprietaryProductIdentifier>
<ProductDescription>XClarity Controller Prem-FOD</ProductDescription>
<ProductName>software1</ProductName>
<ProductTypeCode>Software</ProductTypeCode>
</PartnerProductIdentification>
</ProductIdentification>
<UnitListPrice>
<FinancialAmount>
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
<MonetaryAmount>25.00</MonetaryAmount>
</FinancialAmount>
</UnitListPrice>
<ProductSubLineItem>
<LineNumber>2001</LineNumber>
<TransactionType>ADD</TransactionType>
<Quantity>1</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>LIC-1</ProprietaryProductIdentifier>
<ProductDescription>License</ProductDescription>
<ProductCharacter>SOFTWARE</ProductCharacter>
</PartnerProductIdentification>
</ProductIdentification>
<UnitListPrice>
<FinancialAmount>
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
<MonetaryAmount>0</MonetaryAmount>
</FinancialAmount>
</UnitListPrice>
</ProductSubLineItem>
</ProductLineItem>
<ProductLineItem>
<ProductLineNumber>3000</ProductLineNumber>
<ItemNo>3000</ItemNo>
<TransactionType>NEW</TransactionType>
<ProprietaryGroupIdentifier>3000</ProprietaryGroupIdentifier>
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
<Quantity>2</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
<ProductName>#2</ProductName>
<ProductTypeCode>Hardware</ProductTypeCode>
</PartnerProductIdentification>
</ProductIdentification>
<UnitListPrice>
<FinancialAmount>
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
<MonetaryAmount>90.00</MonetaryAmount>
</FinancialAmount>
</UnitListPrice>
</ProductLineItem>
</CFData>
</CFXML>`
workspace, err := parseCFXMLWorkspace([]byte(sample), "sample.xml")
if err != nil {
t.Fatalf("parseCFXMLWorkspace: %v", err)
}
if workspace.SourceFormat != "CFXML" {
t.Fatalf("unexpected source format: %q", workspace.SourceFormat)
}
if len(workspace.Configurations) != 2 {
t.Fatalf("expected 2 configurations, got %d", len(workspace.Configurations))
}
first := workspace.Configurations[0]
if first.GroupID != "1000" {
t.Fatalf("expected first group 1000, got %q", first.GroupID)
}
if first.Name != "#1" {
t.Fatalf("expected first config name #1, got %q", first.Name)
}
if first.ServerCount != 6 {
t.Fatalf("expected first server count 6, got %d", first.ServerCount)
}
if len(first.Rows) != 4 {
t.Fatalf("expected 4 vendor rows in first config, got %d", len(first.Rows))
}
foundSoftwareTopLevel := false
foundSoftwareSubRow := false
foundPrimaryTopLevelQty := 0
for _, row := range first.Rows {
if row.VendorPartnumber == "7DG9-CTO1WW" {
foundPrimaryTopLevelQty = row.Quantity
}
if row.VendorPartnumber == "7S0X-CTO8WW" {
foundSoftwareTopLevel = true
}
if row.VendorPartnumber == "LIC-1" {
foundSoftwareSubRow = true
}
}
if !foundSoftwareTopLevel {
t.Fatalf("expected software top-level row to stay inside configuration")
}
if !foundSoftwareSubRow {
t.Fatalf("expected software sub-row to stay inside configuration")
}
if foundPrimaryTopLevelQty != 1 {
t.Fatalf("expected primary top-level qty normalized to 1, got %d", foundPrimaryTopLevelQty)
}
if first.TotalPrice == nil || *first.TotalPrice != 750 {
t.Fatalf("expected first total price 750, got %+v", first.TotalPrice)
}
second := workspace.Configurations[1]
if second.Name != "#2" {
t.Fatalf("expected second config name #2, got %q", second.Name)
}
if len(second.Rows) != 1 {
t.Fatalf("expected second config to contain single top-level row, got %d", len(second.Rows))
}
}
func TestImportVendorWorkspaceToProject_AutoResolvesAndAppliesEstimate(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
projectName := "OPS-2079"
if err := local.SaveProject(&localdb.LocalProject{
UUID: "project-1",
OwnerUsername: "tester",
Code: "OPS-2079",
Variant: "",
Name: &projectName,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}); err != nil {
t.Fatalf("save project: %v", err)
}
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 101,
Source: "estimate",
Version: "E-1",
Name: "Estimate",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save estimate pricelist: %v", err)
}
estimatePL, err := local.GetLocalPricelistByServerID(101)
if err != nil {
t.Fatalf("get estimate pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: estimatePL.ID, LotName: "CPU_INTEL_6747P", Price: 1000},
{PricelistID: estimatePL.ID, LotName: "LICENSE_XCC", Price: 50},
}); err != nil {
t.Fatalf("save estimate items: %v", err)
}
bookRepo := local.DB()
if err := bookRepo.Create(&localdb.LocalPartnumberBook{
ServerID: 501,
Version: "B-1",
CreatedAt: time.Now(),
IsActive: true,
}).Error; err != nil {
t.Fatalf("save active book: %v", err)
}
var book localdb.LocalPartnumberBook
if err := bookRepo.Where("server_id = ?", 501).First(&book).Error; err != nil {
t.Fatalf("load active book: %v", err)
}
if err := bookRepo.Create([]localdb.LocalPartnumberBookItem{
{BookID: book.ID, Partnumber: "CPU-1", LotName: "CPU_INTEL_6747P", IsPrimaryPN: true},
{BookID: book.ID, Partnumber: "LIC-1", LotName: "LICENSE_XCC", IsPrimaryPN: true},
}).Error; err != nil {
t.Fatalf("save book items: %v", err)
}
service := NewLocalConfigurationService(local, syncsvc.NewService(nil, local), &QuoteService{}, func() bool { return false })
const sample = `<?xml version="1.0" encoding="UTF-8"?>
<CFXML>
<thisDocumentIdentifier>
<ProprietaryDocumentIdentifier>CFXML.workspace-test</ProprietaryDocumentIdentifier>
</thisDocumentIdentifier>
<CFData>
<ProductLineItem>
<ProductLineNumber>1000</ProductLineNumber>
<ItemNo>1000</ItemNo>
<TransactionType>NEW</TransactionType>
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
<Quantity>2</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
<ProductName>#1</ProductName>
<ProductTypeCode>Hardware</ProductTypeCode>
</PartnerProductIdentification>
</ProductIdentification>
<ProductSubLineItem>
<LineNumber>1001</LineNumber>
<Quantity>2</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>CPU-1</ProprietaryProductIdentifier>
<ProductDescription>CPU</ProductDescription>
</PartnerProductIdentification>
</ProductIdentification>
</ProductSubLineItem>
</ProductLineItem>
<ProductLineItem>
<ProductLineNumber>2000</ProductLineNumber>
<ItemNo>2000</ItemNo>
<TransactionType>NEW</TransactionType>
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
<Quantity>2</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>7S0X-CTO8WW</ProprietaryProductIdentifier>
<ProductDescription>XClarity Controller</ProductDescription>
<ProductName>software1</ProductName>
<ProductTypeCode>Software</ProductTypeCode>
</PartnerProductIdentification>
</ProductIdentification>
<ProductSubLineItem>
<LineNumber>2001</LineNumber>
<Quantity>1</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>LIC-1</ProprietaryProductIdentifier>
<ProductDescription>License</ProductDescription>
</PartnerProductIdentification>
</ProductIdentification>
</ProductSubLineItem>
</ProductLineItem>
</CFData>
</CFXML>`
result, err := service.ImportVendorWorkspaceToProject("project-1", "sample.xml", []byte(sample), "tester")
if err != nil {
t.Fatalf("ImportVendorWorkspaceToProject: %v", err)
}
if result.Imported != 1 || len(result.Configs) != 1 {
t.Fatalf("unexpected import result: %+v", result)
}
cfg, err := local.GetConfigurationByUUID(result.Configs[0].UUID)
if err != nil {
t.Fatalf("load imported config: %v", err)
}
if cfg.PricelistID == nil || *cfg.PricelistID != 101 {
t.Fatalf("expected estimate pricelist id 101, got %+v", cfg.PricelistID)
}
if len(cfg.VendorSpec) != 4 {
t.Fatalf("expected 4 vendor spec rows, got %d", len(cfg.VendorSpec))
}
if len(cfg.Items) != 2 {
t.Fatalf("expected 2 cart items, got %d", len(cfg.Items))
}
if cfg.Items[0].LotName != "CPU_INTEL_6747P" || cfg.Items[0].Quantity != 2 || cfg.Items[0].UnitPrice != 1000 {
t.Fatalf("unexpected first item: %+v", cfg.Items[0])
}
if cfg.Items[1].LotName != "LICENSE_XCC" || cfg.Items[1].Quantity != 1 || cfg.Items[1].UnitPrice != 50 {
t.Fatalf("unexpected second item: %+v", cfg.Items[1])
}
if cfg.TotalPrice == nil || *cfg.TotalPrice != 4100 {
t.Fatalf("expected total price 4100 for 2 servers, got %+v", cfg.TotalPrice)
}
foundCPU := false
foundLIC := false
for _, row := range cfg.VendorSpec {
if row.VendorPartnumber == "CPU-1" {
foundCPU = true
if len(row.LotMappings) != 1 || row.LotMappings[0].LotName != "CPU_INTEL_6747P" || row.LotMappings[0].QuantityPerPN != 1 {
t.Fatalf("unexpected CPU mappings: %+v", row.LotMappings)
}
}
if row.VendorPartnumber == "LIC-1" {
foundLIC = true
if len(row.LotMappings) != 1 || row.LotMappings[0].LotName != "LICENSE_XCC" || row.LotMappings[0].QuantityPerPN != 1 {
t.Fatalf("unexpected LIC mappings: %+v", row.LotMappings)
}
}
}
if !foundCPU || !foundLIC {
t.Fatalf("expected resolved rows for CPU and LIC in vendor spec")
}
}