Version BOM and pricing changes
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user