Version BOM and pricing changes

This commit is contained in:
Mikhail Chusavitin
2026-03-17 18:24:09 +03:00
parent 68cd087356
commit a054fc7564
6 changed files with 201 additions and 31 deletions

View File

@@ -53,9 +53,13 @@ Rules:
Configuration revisions are append-only snapshots stored in `local_configuration_versions`. Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
Rules: 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; - 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; - 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. - current revision pointer must be recoverable if legacy or damaged rows are found locally.
## Naming collisions ## Naming collisions

View File

@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
@@ -14,11 +13,15 @@ import (
// VendorSpecHandler handles vendor BOM spec operations for a configuration. // VendorSpecHandler handles vendor BOM spec operations for a configuration.
type VendorSpecHandler struct { type VendorSpecHandler struct {
localDB *localdb.LocalDB localDB *localdb.LocalDB
configService *services.LocalConfigurationService
} }
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler { 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. // 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) spec := localdb.VendorSpec(body.VendorSpec)
specJSON, err := json.Marshal(spec) if _, err := h.configService.UpdateVendorSpecNoAuth(cfg.UUID, spec); err != nil {
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 {
RespondError(c, http.StatusInternalServerError, "internal server error", err) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -194,13 +192,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
}) })
} }
itemsJSON, err := json.Marshal(newItems) if _, err := h.configService.ApplyVendorSpecItemsNoAuth(cfg.UUID, newItems); err != nil {
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 {
RespondError(c, http.StatusInternalServerError, "internal server error", err) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }

View File

@@ -95,3 +95,60 @@ func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec) 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")
}
}

View File

@@ -112,10 +112,16 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
} }
type configurationSpecPriceFingerprint struct { type configurationSpecPriceFingerprint struct {
Items []configurationSpecPriceFingerprintItem `json:"items"` Items []configurationSpecPriceFingerprintItem `json:"items"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
TotalPrice *float64 `json:"total_price,omitempty"` TotalPrice *float64 `json:"total_price,omitempty"`
CustomPrice *float64 `json:"custom_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 { type configurationSpecPriceFingerprintItem struct {
@@ -146,10 +152,16 @@ func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (strin
}) })
payload := configurationSpecPriceFingerprint{ payload := configurationSpecPriceFingerprint{
Items: items, Items: items,
ServerCount: localCfg.ServerCount, ServerCount: localCfg.ServerCount,
TotalPrice: localCfg.TotalPrice, TotalPrice: localCfg.TotalPrice,
CustomPrice: localCfg.CustomPrice, 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) raw, err := json.Marshal(payload)

View File

@@ -1205,21 +1205,55 @@ func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, nex
current.ServerModel != next.ServerModel || current.ServerModel != next.ServerModel ||
current.SupportCode != next.SupportCode || current.SupportCode != next.SupportCode ||
current.Article != next.Article || current.Article != next.Article ||
current.DisablePriceRefresh != next.DisablePriceRefresh ||
current.OnlyInStock != next.OnlyInStock ||
current.IsActive != next.IsActive || current.IsActive != next.IsActive ||
current.Line != next.Line { current.Line != next.Line {
return true return true
} }
if !equalUintPtr(current.PricelistID, next.PricelistID) || if !equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
!equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) ||
!equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) ||
!equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
return true return true
} }
return false 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 { func equalStringPtr(a, b *string) bool {
if a == nil && b == nil { if a == nil && b == nil {
return true return true

View File

@@ -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) { func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
service, local := newLocalConfigServiceForTest(t) service, local := newLocalConfigServiceForTest(t)