From a054fc75646fbf8c62f63f22fb59bdb41726e2e5 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 17 Mar 2026 18:24:09 +0300 Subject: [PATCH] Version BOM and pricing changes --- bible-local/02-architecture.md | 6 +- internal/handlers/vendor_spec.go | 24 +++---- .../configuration_business_fields_test.go | 57 +++++++++++++++ internal/localdb/snapshots.go | 28 +++++--- internal/services/local_configuration.go | 46 ++++++++++-- .../local_configuration_versioning_test.go | 71 +++++++++++++++++++ 6 files changed, 201 insertions(+), 31 deletions(-) diff --git a/bible-local/02-architecture.md b/bible-local/02-architecture.md index d0a3e35..f6d79d5 100644 --- a/bible-local/02-architecture.md +++ b/bible-local/02-architecture.md @@ -53,9 +53,13 @@ Rules: Configuration revisions are append-only snapshots stored in `local_configuration_versions`. Rules: -- create a new revision only when spec or price content changes; +- the editable working configuration is always the implicit head named `main`; UI must not switch the user to a numbered revision after save; +- create a new revision when spec, BOM, or pricing content changes; +- revision history is retrospective: the revisions page shows past snapshots, not the current `main` state; - rollback creates a new head revision from an old snapshot; - rename, reorder, project move, and similar operational edits do not create a new revision snapshot; +- revision deduplication includes `items`, `server_count`, `total_price`, `custom_price`, `vendor_spec`, pricelist selectors, `disable_price_refresh`, and `only_in_stock`; +- BOM updates must use version-aware save flow, not a direct SQL field update; - current revision pointer must be recoverable if legacy or damaged rows are found locally. ## Naming collisions diff --git a/internal/handlers/vendor_spec.go b/internal/handlers/vendor_spec.go index 932128c..1f6dc24 100644 --- a/internal/handlers/vendor_spec.go +++ b/internal/handlers/vendor_spec.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "errors" "net/http" "strings" @@ -14,11 +13,15 @@ import ( // VendorSpecHandler handles vendor BOM spec operations for a configuration. type VendorSpecHandler struct { - localDB *localdb.LocalDB + localDB *localdb.LocalDB + configService *services.LocalConfigurationService } func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler { - return &VendorSpecHandler{localDB: localDB} + return &VendorSpecHandler{ + localDB: localDB, + configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }), + } } // lookupConfig finds an active configuration by UUID using the standard localDB method. @@ -80,12 +83,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) { } spec := localdb.VendorSpec(body.VendorSpec) - specJSON, err := json.Marshal(spec) - if err != nil { - RespondError(c, http.StatusInternalServerError, "internal server error", err) - return - } - if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil { + if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) return } @@ -194,13 +192,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) { }) } - itemsJSON, err := json.Marshal(newItems) - if err != nil { - RespondError(c, http.StatusInternalServerError, "internal server error", err) - return - } - - if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil { + if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) return } diff --git a/internal/localdb/configuration_business_fields_test.go b/internal/localdb/configuration_business_fields_test.go index b0195d0..cf4bda3 100644 --- a/internal/localdb/configuration_business_fields_test.go +++ b/internal/localdb/configuration_business_fields_test.go @@ -95,3 +95,60 @@ func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) { t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec) } } + +func TestConfigurationFingerprintIncludesPricingSelectorsAndVendorSpec(t *testing.T) { + estimateID := uint(11) + warehouseID := uint(22) + competitorID := uint(33) + + base := &LocalConfiguration{ + UUID: "cfg-1", + Name: "Config", + ServerCount: 1, + Items: LocalConfigItems{{LotName: "LOT_A", Quantity: 1, UnitPrice: 100}}, + PricelistID: &estimateID, + WarehousePricelistID: &warehouseID, + CompetitorPricelistID: &competitorID, + DisablePriceRefresh: true, + OnlyInStock: true, + VendorSpec: VendorSpec{ + { + SortOrder: 10, + VendorPartnumber: "PN-1", + Quantity: 1, + }, + }, + } + + baseFingerprint, err := BuildConfigurationSpecPriceFingerprint(base) + if err != nil { + t.Fatalf("base fingerprint: %v", err) + } + + changedPricelist := *base + newEstimateID := uint(44) + changedPricelist.PricelistID = &newEstimateID + pricelistFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedPricelist) + if err != nil { + t.Fatalf("pricelist fingerprint: %v", err) + } + if pricelistFingerprint == baseFingerprint { + t.Fatalf("expected pricelist selector to affect fingerprint") + } + + changedVendorSpec := *base + changedVendorSpec.VendorSpec = VendorSpec{ + { + SortOrder: 10, + VendorPartnumber: "PN-2", + Quantity: 1, + }, + } + vendorFingerprint, err := BuildConfigurationSpecPriceFingerprint(&changedVendorSpec) + if err != nil { + t.Fatalf("vendor fingerprint: %v", err) + } + if vendorFingerprint == baseFingerprint { + t.Fatalf("expected vendor spec to affect fingerprint") + } +} diff --git a/internal/localdb/snapshots.go b/internal/localdb/snapshots.go index 48cb1ff..09ba9ce 100644 --- a/internal/localdb/snapshots.go +++ b/internal/localdb/snapshots.go @@ -112,10 +112,16 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) { } type configurationSpecPriceFingerprint struct { - Items []configurationSpecPriceFingerprintItem `json:"items"` - ServerCount int `json:"server_count"` - TotalPrice *float64 `json:"total_price,omitempty"` - CustomPrice *float64 `json:"custom_price,omitempty"` + Items []configurationSpecPriceFingerprintItem `json:"items"` + ServerCount int `json:"server_count"` + TotalPrice *float64 `json:"total_price,omitempty"` + CustomPrice *float64 `json:"custom_price,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"` + VendorSpec VendorSpec `json:"vendor_spec,omitempty"` } type configurationSpecPriceFingerprintItem struct { @@ -146,10 +152,16 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin }) payload := configurationSpecPriceFingerprint{ - Items: items, - ServerCount: localCfg.ServerCount, - TotalPrice: localCfg.TotalPrice, - CustomPrice: localCfg.CustomPrice, + Items: items, + ServerCount: localCfg.ServerCount, + TotalPrice: localCfg.TotalPrice, + CustomPrice: localCfg.CustomPrice, + PricelistID: localCfg.PricelistID, + WarehousePricelistID: localCfg.WarehousePricelistID, + CompetitorPricelistID: localCfg.CompetitorPricelistID, + DisablePriceRefresh: localCfg.DisablePriceRefresh, + OnlyInStock: localCfg.OnlyInStock, + VendorSpec: localCfg.VendorSpec, } raw, err := json.Marshal(payload) diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index 370c2e0..6e86d5a 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -1205,21 +1205,55 @@ 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 { return true } - if !equalUintPtr(current.PricelistID, next.PricelistID) || - !equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) || - !equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) || - !equalStringPtr(current.ProjectUUID, next.ProjectUUID) { + if !equalStringPtr(current.ProjectUUID, next.ProjectUUID) { return true } return false } +func (s *LocalConfigurationService) UpdateVendorSpecNoAuth(uuid string, spec localdb.VendorSpec) (*models.Configuration, error) { + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + localCfg.VendorSpec = spec + localCfg.UpdatedAt = time.Now() + localCfg.SyncStatus = "pending" + + cfg, err := s.saveWithVersionAndPending(localCfg, "update", "") + if err != nil { + return nil, fmt.Errorf("update vendor spec without auth with version: %w", err) + } + return cfg, nil +} + +func (s *LocalConfigurationService) ApplyVendorSpecItemsNoAuth(uuid string, items localdb.LocalConfigItems) (*models.Configuration, error) { + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + localCfg.Items = items + total := items.Total() + if localCfg.ServerCount > 1 { + total *= float64(localCfg.ServerCount) + } + localCfg.TotalPrice = &total + localCfg.UpdatedAt = time.Now() + localCfg.SyncStatus = "pending" + + cfg, err := s.saveWithVersionAndPending(localCfg, "update", "") + if err != nil { + return nil, fmt.Errorf("apply vendor spec items without auth with version: %w", err) + } + return cfg, nil +} + func equalStringPtr(a, b *string) bool { if a == nil && b == nil { return true diff --git a/internal/services/local_configuration_versioning_test.go b/internal/services/local_configuration_versioning_test.go index d30ac5f..03f19e2 100644 --- a/internal/services/local_configuration_versioning_test.go +++ b/internal/services/local_configuration_versioning_test.go @@ -137,6 +137,77 @@ func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) { } } +func TestUpdateNoAuthCreatesRevisionWhenPricingSettingsChanged(t *testing.T) { + service, local := newLocalConfigServiceForTest(t) + + created, err := service.Create("tester", &CreateConfigRequest{ + Name: "pricing", + Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}}, + ServerCount: 1, + }) + if err != nil { + t.Fatalf("create config: %v", err) + } + + if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{ + Name: "pricing", + Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}}, + ServerCount: 1, + DisablePriceRefresh: true, + OnlyInStock: true, + }); err != nil { + t.Fatalf("update pricing settings: %v", err) + } + + versions := loadVersions(t, local, created.UUID) + if len(versions) != 2 { + t.Fatalf("expected 2 versions after pricing settings change, got %d", len(versions)) + } + if versions[1].VersionNo != 2 { + t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo) + } +} + +func TestUpdateVendorSpecNoAuthCreatesRevision(t *testing.T) { + service, local := newLocalConfigServiceForTest(t) + + created, err := service.Create("tester", &CreateConfigRequest{ + Name: "bom", + Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}}, + ServerCount: 1, + }) + if err != nil { + t.Fatalf("create config: %v", err) + } + + spec := localdb.VendorSpec{ + { + VendorPartnumber: "PN-001", + Quantity: 2, + SortOrder: 10, + LotMappings: []localdb.VendorSpecLotMapping{ + {LotName: "CPU_A", QuantityPerPN: 1}, + }, + }, + } + if _, err := service.UpdateVendorSpecNoAuth(created.UUID, spec); err != nil { + t.Fatalf("update vendor spec: %v", err) + } + + versions := loadVersions(t, local, created.UUID) + if len(versions) != 2 { + t.Fatalf("expected 2 versions after vendor spec change, got %d", len(versions)) + } + + cfg, err := local.GetConfigurationByUUID(created.UUID) + if err != nil { + t.Fatalf("load config after vendor spec update: %v", err) + } + if len(cfg.VendorSpec) != 1 || cfg.VendorSpec[0].VendorPartnumber != "PN-001" { + t.Fatalf("expected saved vendor spec, got %+v", cfg.VendorSpec) + } +} + func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) { service, local := newLocalConfigServiceForTest(t)