Add vendor workspace import and pricing export workflow
This commit is contained in:
@@ -45,6 +45,14 @@ type ExportRequest struct {
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type ProjectExportOptionsRequest 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"`
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
var req ExportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -213,3 +221,53 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
projectUUID := c.Param("uuid")
|
||||
|
||||
var req ProjectExportOptionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
project, err := h.projectService.GetByUUID(projectUUID, username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.projectService.ListConfigurations(projectUUID, username, "active")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(result.Configs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
||||
return
|
||||
}
|
||||
|
||||
opts := services.ProjectPricingExportOptions{
|
||||
IncludeLOT: req.IncludeLOT,
|
||||
IncludeBOM: req.IncludeBOM,
|
||||
IncludeEstimate: req.IncludeEstimate,
|
||||
IncludeStock: req.IncludeStock,
|
||||
IncludeCompetitor: req.IncludeCompetitor,
|
||||
}
|
||||
|
||||
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
if err := h.exportService.ToPricingCSV(c.Writer, data, opts); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
@@ -66,6 +67,15 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "100"))
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 500 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
// Find local book by server_id
|
||||
var book localdb.LocalPartnumberBook
|
||||
@@ -74,17 +84,23 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
items, err := bookRepo.GetBookItems(book.ID)
|
||||
items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"book_id": book.ServerID,
|
||||
"version": book.Version,
|
||||
"is_active": book.IsActive,
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
"book_id": book.ServerID,
|
||||
"version": book.Version,
|
||||
"is_active": book.IsActive,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"search": search,
|
||||
"book_total": bookRepo.CountBookItems(book.ID),
|
||||
"lot_count": bookRepo.CountDistinctLots(book.ID),
|
||||
"primary_count": bookRepo.CountPrimaryItems(book.ID),
|
||||
})
|
||||
}
|
||||
|
||||
97
internal/localdb/configuration_business_fields_test.go
Normal file
97
internal/localdb/configuration_business_fields_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
)
|
||||
|
||||
func TestConfigurationConvertersPreserveBusinessFields(t *testing.T) {
|
||||
estimateID := uint(11)
|
||||
warehouseID := uint(22)
|
||||
competitorID := uint(33)
|
||||
|
||||
cfg := &models.Configuration{
|
||||
UUID: "cfg-1",
|
||||
OwnerUsername: "tester",
|
||||
Name: "Config",
|
||||
PricelistID: &estimateID,
|
||||
WarehousePricelistID: &warehouseID,
|
||||
CompetitorPricelistID: &competitorID,
|
||||
DisablePriceRefresh: true,
|
||||
OnlyInStock: true,
|
||||
}
|
||||
|
||||
local := ConfigurationToLocal(cfg)
|
||||
if local.WarehousePricelistID == nil || *local.WarehousePricelistID != warehouseID {
|
||||
t.Fatalf("warehouse pricelist lost in ConfigurationToLocal: %+v", local.WarehousePricelistID)
|
||||
}
|
||||
if local.CompetitorPricelistID == nil || *local.CompetitorPricelistID != competitorID {
|
||||
t.Fatalf("competitor pricelist lost in ConfigurationToLocal: %+v", local.CompetitorPricelistID)
|
||||
}
|
||||
if !local.DisablePriceRefresh {
|
||||
t.Fatalf("disable_price_refresh lost in ConfigurationToLocal")
|
||||
}
|
||||
|
||||
back := LocalToConfiguration(local)
|
||||
if back.WarehousePricelistID == nil || *back.WarehousePricelistID != warehouseID {
|
||||
t.Fatalf("warehouse pricelist lost in LocalToConfiguration: %+v", back.WarehousePricelistID)
|
||||
}
|
||||
if back.CompetitorPricelistID == nil || *back.CompetitorPricelistID != competitorID {
|
||||
t.Fatalf("competitor pricelist lost in LocalToConfiguration: %+v", back.CompetitorPricelistID)
|
||||
}
|
||||
if !back.DisablePriceRefresh {
|
||||
t.Fatalf("disable_price_refresh lost in LocalToConfiguration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
|
||||
estimateID := uint(11)
|
||||
warehouseID := uint(22)
|
||||
competitorID := uint(33)
|
||||
|
||||
cfg := &LocalConfiguration{
|
||||
UUID: "cfg-1",
|
||||
Name: "Config",
|
||||
PricelistID: &estimateID,
|
||||
WarehousePricelistID: &warehouseID,
|
||||
CompetitorPricelistID: &competitorID,
|
||||
DisablePriceRefresh: true,
|
||||
OnlyInStock: true,
|
||||
VendorSpec: VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "PN-1",
|
||||
Quantity: 1,
|
||||
LotMappings: []VendorSpecLotMapping{
|
||||
{LotName: "LOT_A", QuantityPerPN: 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
raw, err := BuildConfigurationSnapshot(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfigurationSnapshot: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := DecodeConfigurationSnapshot(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeConfigurationSnapshot: %v", err)
|
||||
}
|
||||
if decoded.WarehousePricelistID == nil || *decoded.WarehousePricelistID != warehouseID {
|
||||
t.Fatalf("warehouse pricelist lost in snapshot: %+v", decoded.WarehousePricelistID)
|
||||
}
|
||||
if decoded.CompetitorPricelistID == nil || *decoded.CompetitorPricelistID != competitorID {
|
||||
t.Fatalf("competitor pricelist lost in snapshot: %+v", decoded.CompetitorPricelistID)
|
||||
}
|
||||
if !decoded.DisablePriceRefresh {
|
||||
t.Fatalf("disable_price_refresh lost in snapshot")
|
||||
}
|
||||
if len(decoded.VendorSpec) != 1 || decoded.VendorSpec[0].VendorPartnumber != "PN-1" {
|
||||
t.Fatalf("vendor_spec lost in snapshot: %+v", decoded.VendorSpec)
|
||||
}
|
||||
if len(decoded.VendorSpec[0].LotMappings) != 1 || decoded.VendorSpec[0].LotMappings[0].LotName != "LOT_A" {
|
||||
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
|
||||
}
|
||||
}
|
||||
@@ -18,28 +18,32 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
}
|
||||
|
||||
local := &LocalConfiguration{
|
||||
UUID: cfg.UUID,
|
||||
ProjectUUID: cfg.ProjectUUID,
|
||||
IsActive: true,
|
||||
Name: cfg.Name,
|
||||
Items: items,
|
||||
TotalPrice: cfg.TotalPrice,
|
||||
CustomPrice: cfg.CustomPrice,
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
ServerModel: cfg.ServerModel,
|
||||
SupportCode: cfg.SupportCode,
|
||||
Article: cfg.Article,
|
||||
PricelistID: cfg.PricelistID,
|
||||
OnlyInStock: cfg.OnlyInStock,
|
||||
Line: cfg.Line,
|
||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUserID: derefUint(cfg.UserID),
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
UUID: cfg.UUID,
|
||||
ProjectUUID: cfg.ProjectUUID,
|
||||
IsActive: true,
|
||||
Name: cfg.Name,
|
||||
Items: items,
|
||||
TotalPrice: cfg.TotalPrice,
|
||||
CustomPrice: cfg.CustomPrice,
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
ServerModel: cfg.ServerModel,
|
||||
SupportCode: cfg.SupportCode,
|
||||
Article: cfg.Article,
|
||||
PricelistID: cfg.PricelistID,
|
||||
WarehousePricelistID: cfg.WarehousePricelistID,
|
||||
CompetitorPricelistID: cfg.CompetitorPricelistID,
|
||||
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
|
||||
DisablePriceRefresh: cfg.DisablePriceRefresh,
|
||||
OnlyInStock: cfg.OnlyInStock,
|
||||
Line: cfg.Line,
|
||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUserID: derefUint(cfg.UserID),
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
}
|
||||
|
||||
if local.OriginalUsername == "" && cfg.User != nil {
|
||||
@@ -66,24 +70,28 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
}
|
||||
|
||||
cfg := &models.Configuration{
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OriginalUsername,
|
||||
ProjectUUID: local.ProjectUUID,
|
||||
Name: local.Name,
|
||||
Items: items,
|
||||
TotalPrice: local.TotalPrice,
|
||||
CustomPrice: local.CustomPrice,
|
||||
Notes: local.Notes,
|
||||
IsTemplate: local.IsTemplate,
|
||||
ServerCount: local.ServerCount,
|
||||
ServerModel: local.ServerModel,
|
||||
SupportCode: local.SupportCode,
|
||||
Article: local.Article,
|
||||
PricelistID: local.PricelistID,
|
||||
OnlyInStock: local.OnlyInStock,
|
||||
Line: local.Line,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
CreatedAt: local.CreatedAt,
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OriginalUsername,
|
||||
ProjectUUID: local.ProjectUUID,
|
||||
Name: local.Name,
|
||||
Items: items,
|
||||
TotalPrice: local.TotalPrice,
|
||||
CustomPrice: local.CustomPrice,
|
||||
Notes: local.Notes,
|
||||
IsTemplate: local.IsTemplate,
|
||||
ServerCount: local.ServerCount,
|
||||
ServerModel: local.ServerModel,
|
||||
SupportCode: local.SupportCode,
|
||||
Article: local.Article,
|
||||
PricelistID: local.PricelistID,
|
||||
WarehousePricelistID: local.WarehousePricelistID,
|
||||
CompetitorPricelistID: local.CompetitorPricelistID,
|
||||
VendorSpec: localVendorSpecToModel(local.VendorSpec),
|
||||
DisablePriceRefresh: local.DisablePriceRefresh,
|
||||
OnlyInStock: local.OnlyInStock,
|
||||
Line: local.Line,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
CreatedAt: local.CreatedAt,
|
||||
}
|
||||
|
||||
if local.ServerID != nil {
|
||||
@@ -107,6 +115,88 @@ func derefUint(v *uint) uint {
|
||||
return *v
|
||||
}
|
||||
|
||||
func modelVendorSpecToLocal(spec models.VendorSpec) VendorSpec {
|
||||
if len(spec) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(VendorSpec, 0, len(spec))
|
||||
for _, item := range spec {
|
||||
row := VendorSpecItem{
|
||||
SortOrder: item.SortOrder,
|
||||
VendorPartnumber: item.VendorPartnumber,
|
||||
Quantity: item.Quantity,
|
||||
Description: item.Description,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: item.TotalPrice,
|
||||
ResolvedLotName: item.ResolvedLotName,
|
||||
ResolutionSource: item.ResolutionSource,
|
||||
ManualLotSuggestion: item.ManualLotSuggestion,
|
||||
LotQtyPerPN: item.LotQtyPerPN,
|
||||
}
|
||||
if len(item.LotAllocations) > 0 {
|
||||
row.LotAllocations = make([]VendorSpecLotAllocation, 0, len(item.LotAllocations))
|
||||
for _, alloc := range item.LotAllocations {
|
||||
row.LotAllocations = append(row.LotAllocations, VendorSpecLotAllocation{
|
||||
LotName: alloc.LotName,
|
||||
Quantity: alloc.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(item.LotMappings) > 0 {
|
||||
row.LotMappings = make([]VendorSpecLotMapping, 0, len(item.LotMappings))
|
||||
for _, mapping := range item.LotMappings {
|
||||
row.LotMappings = append(row.LotMappings, VendorSpecLotMapping{
|
||||
LotName: mapping.LotName,
|
||||
QuantityPerPN: mapping.QuantityPerPN,
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func localVendorSpecToModel(spec VendorSpec) models.VendorSpec {
|
||||
if len(spec) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(models.VendorSpec, 0, len(spec))
|
||||
for _, item := range spec {
|
||||
row := models.VendorSpecItem{
|
||||
SortOrder: item.SortOrder,
|
||||
VendorPartnumber: item.VendorPartnumber,
|
||||
Quantity: item.Quantity,
|
||||
Description: item.Description,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: item.TotalPrice,
|
||||
ResolvedLotName: item.ResolvedLotName,
|
||||
ResolutionSource: item.ResolutionSource,
|
||||
ManualLotSuggestion: item.ManualLotSuggestion,
|
||||
LotQtyPerPN: item.LotQtyPerPN,
|
||||
}
|
||||
if len(item.LotAllocations) > 0 {
|
||||
row.LotAllocations = make([]models.VendorSpecLotAllocation, 0, len(item.LotAllocations))
|
||||
for _, alloc := range item.LotAllocations {
|
||||
row.LotAllocations = append(row.LotAllocations, models.VendorSpecLotAllocation{
|
||||
LotName: alloc.LotName,
|
||||
Quantity: alloc.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(item.LotMappings) > 0 {
|
||||
row.LotMappings = make([]models.VendorSpecLotMapping, 0, len(item.LotMappings))
|
||||
for _, mapping := range item.LotMappings {
|
||||
row.LotMappings = append(row.LotMappings, models.VendorSpecLotMapping{
|
||||
LotName: mapping.LotName,
|
||||
QuantityPerPN: mapping.QuantityPerPN,
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ProjectToLocal(project *models.Project) *LocalProject {
|
||||
local := &LocalProject{
|
||||
UUID: project.UUID,
|
||||
|
||||
@@ -102,8 +102,9 @@ type LocalConfiguration struct {
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
|
||||
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -274,18 +275,18 @@ func (LocalPartnumberBookItem) TableName() string {
|
||||
|
||||
// VendorSpecItem represents a single row in a vendor BOM specification
|
||||
type VendorSpecItem struct {
|
||||
SortOrder int `json:"sort_order"`
|
||||
VendorPartnumber string `json:"vendor_partnumber"`
|
||||
Quantity int `json:"quantity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
||||
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
|
||||
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
|
||||
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
VendorPartnumber string `json:"vendor_partnumber"`
|
||||
Quantity int `json:"quantity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
||||
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
|
||||
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
|
||||
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
|
||||
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
|
||||
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||
}
|
||||
|
||||
type VendorSpecLotAllocation struct {
|
||||
|
||||
@@ -10,32 +10,36 @@ import (
|
||||
// BuildConfigurationSnapshot serializes the full local configuration state.
|
||||
func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
snapshot := map[string]interface{}{
|
||||
"id": localCfg.ID,
|
||||
"uuid": localCfg.UUID,
|
||||
"server_id": localCfg.ServerID,
|
||||
"project_uuid": localCfg.ProjectUUID,
|
||||
"current_version_id": localCfg.CurrentVersionID,
|
||||
"is_active": localCfg.IsActive,
|
||||
"name": localCfg.Name,
|
||||
"items": localCfg.Items,
|
||||
"total_price": localCfg.TotalPrice,
|
||||
"custom_price": localCfg.CustomPrice,
|
||||
"notes": localCfg.Notes,
|
||||
"is_template": localCfg.IsTemplate,
|
||||
"server_count": localCfg.ServerCount,
|
||||
"server_model": localCfg.ServerModel,
|
||||
"support_code": localCfg.SupportCode,
|
||||
"article": localCfg.Article,
|
||||
"pricelist_id": localCfg.PricelistID,
|
||||
"only_in_stock": localCfg.OnlyInStock,
|
||||
"line": localCfg.Line,
|
||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||
"created_at": localCfg.CreatedAt,
|
||||
"updated_at": localCfg.UpdatedAt,
|
||||
"synced_at": localCfg.SyncedAt,
|
||||
"sync_status": localCfg.SyncStatus,
|
||||
"original_user_id": localCfg.OriginalUserID,
|
||||
"original_username": localCfg.OriginalUsername,
|
||||
"id": localCfg.ID,
|
||||
"uuid": localCfg.UUID,
|
||||
"server_id": localCfg.ServerID,
|
||||
"project_uuid": localCfg.ProjectUUID,
|
||||
"current_version_id": localCfg.CurrentVersionID,
|
||||
"is_active": localCfg.IsActive,
|
||||
"name": localCfg.Name,
|
||||
"items": localCfg.Items,
|
||||
"total_price": localCfg.TotalPrice,
|
||||
"custom_price": localCfg.CustomPrice,
|
||||
"notes": localCfg.Notes,
|
||||
"is_template": localCfg.IsTemplate,
|
||||
"server_count": localCfg.ServerCount,
|
||||
"server_model": localCfg.ServerModel,
|
||||
"support_code": localCfg.SupportCode,
|
||||
"article": localCfg.Article,
|
||||
"pricelist_id": localCfg.PricelistID,
|
||||
"warehouse_pricelist_id": localCfg.WarehousePricelistID,
|
||||
"competitor_pricelist_id": localCfg.CompetitorPricelistID,
|
||||
"disable_price_refresh": localCfg.DisablePriceRefresh,
|
||||
"only_in_stock": localCfg.OnlyInStock,
|
||||
"vendor_spec": localCfg.VendorSpec,
|
||||
"line": localCfg.Line,
|
||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||
"created_at": localCfg.CreatedAt,
|
||||
"updated_at": localCfg.UpdatedAt,
|
||||
"synced_at": localCfg.SyncedAt,
|
||||
"sync_status": localCfg.SyncStatus,
|
||||
"original_user_id": localCfg.OriginalUserID,
|
||||
"original_username": localCfg.OriginalUsername,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(snapshot)
|
||||
@@ -48,24 +52,28 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
|
||||
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
var snapshot struct {
|
||||
ProjectUUID *string `json:"project_uuid"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Name string `json:"name"`
|
||||
Items LocalConfigItems `json:"items"`
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
ServerModel string `json:"server_model"`
|
||||
SupportCode string `json:"support_code"`
|
||||
Article string `json:"article"`
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
Line int `json:"line"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
OriginalUserID uint `json:"original_user_id"`
|
||||
OriginalUsername string `json:"original_username"`
|
||||
ProjectUUID *string `json:"project_uuid"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Name string `json:"name"`
|
||||
Items LocalConfigItems `json:"items"`
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
ServerModel string `json:"server_model"`
|
||||
SupportCode string `json:"support_code"`
|
||||
Article string `json:"article"`
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
WarehousePricelistID *uint `json:"warehouse_pricelist_id"`
|
||||
CompetitorPricelistID *uint `json:"competitor_pricelist_id"`
|
||||
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
VendorSpec VendorSpec `json:"vendor_spec"`
|
||||
Line int `json:"line"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
OriginalUserID uint `json:"original_user_id"`
|
||||
OriginalUsername string `json:"original_username"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
|
||||
@@ -78,24 +86,28 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
}
|
||||
|
||||
return &LocalConfiguration{
|
||||
IsActive: isActive,
|
||||
ProjectUUID: snapshot.ProjectUUID,
|
||||
Name: snapshot.Name,
|
||||
Items: snapshot.Items,
|
||||
TotalPrice: snapshot.TotalPrice,
|
||||
CustomPrice: snapshot.CustomPrice,
|
||||
Notes: snapshot.Notes,
|
||||
IsTemplate: snapshot.IsTemplate,
|
||||
ServerCount: snapshot.ServerCount,
|
||||
ServerModel: snapshot.ServerModel,
|
||||
SupportCode: snapshot.SupportCode,
|
||||
Article: snapshot.Article,
|
||||
PricelistID: snapshot.PricelistID,
|
||||
OnlyInStock: snapshot.OnlyInStock,
|
||||
Line: snapshot.Line,
|
||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||
OriginalUserID: snapshot.OriginalUserID,
|
||||
OriginalUsername: snapshot.OriginalUsername,
|
||||
IsActive: isActive,
|
||||
ProjectUUID: snapshot.ProjectUUID,
|
||||
Name: snapshot.Name,
|
||||
Items: snapshot.Items,
|
||||
TotalPrice: snapshot.TotalPrice,
|
||||
CustomPrice: snapshot.CustomPrice,
|
||||
Notes: snapshot.Notes,
|
||||
IsTemplate: snapshot.IsTemplate,
|
||||
ServerCount: snapshot.ServerCount,
|
||||
ServerModel: snapshot.ServerModel,
|
||||
SupportCode: snapshot.SupportCode,
|
||||
Article: snapshot.Article,
|
||||
PricelistID: snapshot.PricelistID,
|
||||
WarehousePricelistID: snapshot.WarehousePricelistID,
|
||||
CompetitorPricelistID: snapshot.CompetitorPricelistID,
|
||||
DisablePriceRefresh: snapshot.DisablePriceRefresh,
|
||||
OnlyInStock: snapshot.OnlyInStock,
|
||||
VendorSpec: snapshot.VendorSpec,
|
||||
Line: snapshot.Line,
|
||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||
OriginalUserID: snapshot.OriginalUserID,
|
||||
OriginalUsername: snapshot.OriginalUsername,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,57 @@ func (c ConfigItems) Total() float64 {
|
||||
return total
|
||||
}
|
||||
|
||||
type VendorSpecLotAllocation struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
type VendorSpecLotMapping struct {
|
||||
LotName string `json:"lot_name"`
|
||||
QuantityPerPN int `json:"quantity_per_pn"`
|
||||
}
|
||||
|
||||
type VendorSpecItem struct {
|
||||
SortOrder int `json:"sort_order"`
|
||||
VendorPartnumber string `json:"vendor_partnumber"`
|
||||
Quantity int `json:"quantity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
||||
ResolutionSource string `json:"resolution_source,omitempty"`
|
||||
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
|
||||
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
|
||||
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
|
||||
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||
}
|
||||
|
||||
type VendorSpec []VendorSpecItem
|
||||
|
||||
func (v VendorSpec) Value() (driver.Value, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (v *VendorSpec) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*v = nil
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch val := value.(type) {
|
||||
case []byte:
|
||||
bytes = val
|
||||
case string:
|
||||
bytes = []byte(val)
|
||||
default:
|
||||
return errors.New("type assertion failed for VendorSpec")
|
||||
}
|
||||
return json.Unmarshal(bytes, v)
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
@@ -59,6 +110,7 @@ type Configuration struct {
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"`
|
||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
|
||||
@@ -31,6 +31,35 @@ func (r *PartnumberBookRepository) GetBookItems(bookID uint) ([]localdb.LocalPar
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetBookItemsPage returns items for the given local book ID with optional search and pagination.
|
||||
func (r *PartnumberBookRepository) GetBookItemsPage(bookID uint, search string, page, perPage int) ([]localdb.LocalPartnumberBookItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("book_id = ?", bookID)
|
||||
trimmedSearch := "%" + search + "%"
|
||||
if search != "" {
|
||||
query = query.Where("partnumber LIKE ? OR lot_name LIKE ?", trimmedSearch, trimmedSearch)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var items []localdb.LocalPartnumberBookItem
|
||||
err := query.
|
||||
Order("partnumber ASC, lot_name ASC, id ASC").
|
||||
Offset((page - 1) * perPage).
|
||||
Limit(perPage).
|
||||
Find(&items).Error
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// FindLotByPartnumber looks up a partnumber in the active book and returns the matching items.
|
||||
func (r *PartnumberBookRepository) FindLotByPartnumber(bookID uint, partnumber string) ([]localdb.LocalPartnumberBookItem, error) {
|
||||
var items []localdb.LocalPartnumberBookItem
|
||||
@@ -64,3 +93,20 @@ func (r *PartnumberBookRepository) CountBookItems(bookID uint) int64 {
|
||||
r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("book_id = ?", bookID).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
func (r *PartnumberBookRepository) CountDistinctLots(bookID uint) int64 {
|
||||
var count int64
|
||||
r.db.Model(&localdb.LocalPartnumberBookItem{}).
|
||||
Where("book_id = ?", bookID).
|
||||
Distinct("lot_name").
|
||||
Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
func (r *PartnumberBookRepository) CountPrimaryItems(bookID uint) int64 {
|
||||
var count int64
|
||||
r.db.Model(&localdb.LocalPartnumberBookItem{}).
|
||||
Where("book_id = ? AND is_primary_pn = ?", bookID, true).
|
||||
Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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)
|
||||
|
||||
560
internal/services/vendor_workspace_import.go
Normal file
560
internal/services/vendor_workspace_import.go
Normal 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 "))
|
||||
}
|
||||
363
internal/services/vendor_workspace_import_test.go
Normal file
363
internal/services/vendor_workspace_import_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user