Fix auto pricelist resolution and latest-price selection; update Bible
This commit is contained in:
@@ -118,6 +118,26 @@ A configuration can reference up to three pricelists simultaneously:
|
|||||||
|
|
||||||
Pricelist sources: `estimate` | `warehouse` | `competitor`
|
Pricelist sources: `estimate` | `warehouse` | `competitor`
|
||||||
|
|
||||||
|
### "Auto" Pricelist Selection
|
||||||
|
|
||||||
|
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
|
||||||
|
|
||||||
|
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
|
||||||
|
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
|
||||||
|
|
||||||
|
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
|
||||||
|
- resolved IDs are runtime-only and must not overwrite user's mode;
|
||||||
|
- switching to explicit selection must clear runtime auto resolution for that source.
|
||||||
|
|
||||||
|
### Latest Pricelist Resolution Rules
|
||||||
|
|
||||||
|
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
|
||||||
|
|
||||||
|
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
|
||||||
|
2. deterministic sort: `created_at DESC, id DESC`.
|
||||||
|
|
||||||
|
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration Versioning
|
## Configuration Versioning
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
|
|||||||
INDEX local_pricelist_items(pricelist_id)
|
INDEX local_pricelist_items(pricelist_id)
|
||||||
UNIQUE INDEX local_pricelists(server_id)
|
UNIQUE INDEX local_pricelists(server_id)
|
||||||
INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
|
INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
|
||||||
|
-- latest-by-source runtime query also applies deterministic tie-break by id DESC
|
||||||
|
|
||||||
-- Configurations
|
-- Configurations
|
||||||
INDEX local_configurations(pricelist_id)
|
INDEX local_configurations(pricelist_id)
|
||||||
|
|||||||
@@ -31,12 +31,14 @@
|
|||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Endpoint | Purpose |
|
||||||
|--------|----------|---------|
|
|--------|----------|---------|
|
||||||
| GET | `/api/pricelists` | List pricelists |
|
| GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) |
|
||||||
| GET | `/api/pricelists/latest` | Latest pricelist by source |
|
| GET | `/api/pricelists/latest` | Latest pricelist by source |
|
||||||
| GET | `/api/pricelists/:id` | Pricelist by ID |
|
| GET | `/api/pricelists/:id` | Pricelist by ID |
|
||||||
| GET | `/api/pricelists/:id/items` | Pricelist line items |
|
| GET | `/api/pricelists/:id/items` | Pricelist line items |
|
||||||
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
|
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist |
|
||||||
|
|
||||||
|
`GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`).
|
||||||
|
|
||||||
### Configurations
|
### Configurations
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Endpoint | Purpose |
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
| GET | `/api/configs/:uuid` | Get configuration |
|
| GET | `/api/configs/:uuid` | Get configuration |
|
||||||
| PUT | `/api/configs/:uuid` | Update configuration |
|
| PUT | `/api/configs/:uuid` | Update configuration |
|
||||||
| DELETE | `/api/configs/:uuid` | Archive configuration |
|
| DELETE | `/api/configs/:uuid` | Archive configuration |
|
||||||
| POST | `/api/configs/:uuid/refresh` | Refresh prices from pricelist |
|
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
|
||||||
| POST | `/api/configs/:uuid/clone` | Clone configuration |
|
| POST | `/api/configs/:uuid/clone` | Clone configuration |
|
||||||
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
|
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
|
||||||
| POST | `/api/configs/:uuid/rename` | Rename configuration |
|
| POST | `/api/configs/:uuid/rename` | Rename configuration |
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ if found && price > 0 {
|
|||||||
|
|
||||||
**Problem: configuration refresh does not update prices**
|
**Problem: configuration refresh does not update prices**
|
||||||
1. Refresh uses the latest estimate pricelist by default
|
1. Refresh uses the latest estimate pricelist by default
|
||||||
2. `local_pricelist_items` must have data
|
2. Latest resolution ignores pricelists without items (`EXISTS local_pricelist_items`)
|
||||||
3. Old prices in `config.items` are preserved if a line item is not found in the pricelist
|
3. Old prices in `config.items` are preserved if a line item is not found in the pricelist
|
||||||
4. To force a pricelist update: set `configuration.pricelist_id`
|
4. To force a pricelist update: set `configuration.pricelist_id`
|
||||||
|
5. In configurator, `Авто` must remain auto-mode (runtime resolved ID must not be persisted as explicit selection)
|
||||||
|
|||||||
@@ -46,8 +46,30 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
localPLs = filtered
|
localPLs = filtered
|
||||||
}
|
}
|
||||||
if activeOnly {
|
type pricelistWithCount struct {
|
||||||
// Local cache stores only active snapshots for normal operations.
|
pricelist localdb.LocalPricelist
|
||||||
|
itemCount int64
|
||||||
|
usageCount int
|
||||||
|
}
|
||||||
|
withCounts := make([]pricelistWithCount, 0, len(localPLs))
|
||||||
|
for _, lpl := range localPLs {
|
||||||
|
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
||||||
|
if activeOnly && itemCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
usageCount := 0
|
||||||
|
if lpl.IsUsed {
|
||||||
|
usageCount = 1
|
||||||
|
}
|
||||||
|
withCounts = append(withCounts, pricelistWithCount{
|
||||||
|
pricelist: lpl,
|
||||||
|
itemCount: itemCount,
|
||||||
|
usageCount: usageCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
localPLs = localPLs[:0]
|
||||||
|
for _, row := range withCounts {
|
||||||
|
localPLs = append(localPLs, row.pricelist)
|
||||||
}
|
}
|
||||||
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
|
sort.SliceStable(localPLs, func(i, j int) bool { return localPLs[i].CreatedAt.After(localPLs[j].CreatedAt) })
|
||||||
total := len(localPLs)
|
total := len(localPLs)
|
||||||
@@ -62,10 +84,14 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
|||||||
pageSlice := localPLs[start:end]
|
pageSlice := localPLs[start:end]
|
||||||
summaries := make([]map[string]interface{}, 0, len(pageSlice))
|
summaries := make([]map[string]interface{}, 0, len(pageSlice))
|
||||||
for _, lpl := range pageSlice {
|
for _, lpl := range pageSlice {
|
||||||
itemCount := h.localDB.CountLocalPricelistItems(lpl.ID)
|
itemCount := int64(0)
|
||||||
usageCount := 0
|
usageCount := 0
|
||||||
if lpl.IsUsed {
|
for _, row := range withCounts {
|
||||||
usageCount = 1
|
if row.pricelist.ID == lpl.ID {
|
||||||
|
itemCount = row.itemCount
|
||||||
|
usageCount = row.usageCount
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
summaries = append(summaries, map[string]interface{}{
|
summaries = append(summaries, map[string]interface{}{
|
||||||
"id": lpl.ServerID,
|
"id": lpl.ServerID,
|
||||||
|
|||||||
@@ -82,3 +82,80 @@ func TestPricelistGetItems_ReturnsLotCategoryFromLocalPricelistItems(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local_active_only.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init local db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 10,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "E-1",
|
||||||
|
Name: "with-items",
|
||||||
|
CreatedAt: time.Now().Add(-time.Minute),
|
||||||
|
SyncedAt: time.Now().Add(-time.Minute),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save with-items pricelist: %v", err)
|
||||||
|
}
|
||||||
|
withItems, err := local.GetLocalPricelistByServerID(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load with-items pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{
|
||||||
|
PricelistID: withItems.ID,
|
||||||
|
LotName: "CPU_X",
|
||||||
|
LotCategory: "CPU",
|
||||||
|
Price: 100,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save with-items pricelist items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 11,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "E-2",
|
||||||
|
Name: "without-items",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save without-items pricelist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewPricelistHandler(local)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/api/pricelists?source=estimate&active_only=true", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
h.List(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Pricelists []struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
} `json:"pricelists"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Total != 1 {
|
||||||
|
t.Fatalf("expected total=1, got %d", resp.Total)
|
||||||
|
}
|
||||||
|
if len(resp.Pricelists) != 1 {
|
||||||
|
t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists))
|
||||||
|
}
|
||||||
|
if resp.Pricelists[0].ID != 10 {
|
||||||
|
t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -692,7 +692,11 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
|||||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
var pricelist LocalPricelist
|
||||||
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&pricelist).Error; err != nil {
|
if err := l.db.
|
||||||
|
Where("source = ?", "estimate").
|
||||||
|
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||||
|
Order("created_at DESC, id DESC").
|
||||||
|
First(&pricelist).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
@@ -701,7 +705,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
|||||||
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
||||||
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
var pricelist LocalPricelist
|
||||||
if err := l.db.Where("source = ?", source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
if err := l.db.
|
||||||
|
Where("source = ?", source).
|
||||||
|
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||||
|
Order("created_at DESC, id DESC").
|
||||||
|
First(&pricelist).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
|
|||||||
128
internal/localdb/pricelist_latest_test.go
Normal file
128
internal/localdb/pricelist_latest_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetLatestLocalPricelistBySource_SkipsPricelistWithoutItems(t *testing.T) {
|
||||||
|
local, err := New(filepath.Join(t.TempDir(), "latest_without_items.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
base := time.Now().Add(-time.Minute)
|
||||||
|
withItems := &LocalPricelist{
|
||||||
|
ServerID: 1001,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "E-1",
|
||||||
|
Name: "with-items",
|
||||||
|
CreatedAt: base,
|
||||||
|
SyncedAt: base,
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelist(withItems); err != nil {
|
||||||
|
t.Fatalf("save pricelist with items: %v", err)
|
||||||
|
}
|
||||||
|
storedWithItems, err := local.GetLocalPricelistByServerID(withItems.ServerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load pricelist with items: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||||
|
{
|
||||||
|
PricelistID: storedWithItems.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
Price: 100,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save pricelist items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutItems := &LocalPricelist{
|
||||||
|
ServerID: 1002,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "E-2",
|
||||||
|
Name: "without-items",
|
||||||
|
CreatedAt: base.Add(2 * time.Second),
|
||||||
|
SyncedAt: base.Add(2 * time.Second),
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelist(withoutItems); err != nil {
|
||||||
|
t.Fatalf("save pricelist without items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := local.GetLatestLocalPricelistBySource("estimate")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
||||||
|
}
|
||||||
|
if got.ServerID != withItems.ServerID {
|
||||||
|
t.Fatalf("expected server_id=%d, got %d", withItems.ServerID, got.ServerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLatestLocalPricelistBySource_TieBreaksByID(t *testing.T) {
|
||||||
|
local, err := New(filepath.Join(t.TempDir(), "latest_tie_break.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
base := time.Now().Add(-time.Minute)
|
||||||
|
first := &LocalPricelist{
|
||||||
|
ServerID: 2001,
|
||||||
|
Source: "warehouse",
|
||||||
|
Version: "S-1",
|
||||||
|
Name: "first",
|
||||||
|
CreatedAt: base,
|
||||||
|
SyncedAt: base,
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelist(first); err != nil {
|
||||||
|
t.Fatalf("save first pricelist: %v", err)
|
||||||
|
}
|
||||||
|
storedFirst, err := local.GetLocalPricelistByServerID(first.ServerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load first pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||||
|
{
|
||||||
|
PricelistID: storedFirst.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
Price: 101,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save first items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := &LocalPricelist{
|
||||||
|
ServerID: 2002,
|
||||||
|
Source: "warehouse",
|
||||||
|
Version: "S-2",
|
||||||
|
Name: "second",
|
||||||
|
CreatedAt: base,
|
||||||
|
SyncedAt: base,
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelist(second); err != nil {
|
||||||
|
t.Fatalf("save second pricelist: %v", err)
|
||||||
|
}
|
||||||
|
storedSecond, err := local.GetLocalPricelistByServerID(second.ServerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load second pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]LocalPricelistItem{
|
||||||
|
{
|
||||||
|
PricelistID: storedSecond.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
Price: 102,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save second items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := local.GetLatestLocalPricelistBySource("warehouse")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestLocalPricelistBySource: %v", err)
|
||||||
|
}
|
||||||
|
if got.ServerID != second.ServerID {
|
||||||
|
t.Fatalf("expected server_id=%d, got %d", second.ServerID, got.ServerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pricelists []models.Pricelist
|
var pricelists []models.Pricelist
|
||||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||||
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ func (r *PricelistRepository) ListActiveBySource(source string, offset, limit in
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pricelists []models.Pricelist
|
var pricelists []models.Pricelist
|
||||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||||
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,11 @@ func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
|||||||
// GetLatestActiveBySource returns the most recent active pricelist by source.
|
// GetLatestActiveBySource returns the most recent active pricelist by source.
|
||||||
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
||||||
var pricelist models.Pricelist
|
var pricelist models.Pricelist
|
||||||
if err := r.db.Where("is_active = ? AND source = ?", true, source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
if err := r.db.
|
||||||
|
Where("is_active = ? AND source = ?", true, source).
|
||||||
|
Where("EXISTS (SELECT 1 FROM qt_pricelist_items WHERE qt_pricelist_items.pricelist_id = qt_pricelists.id)").
|
||||||
|
Order("created_at DESC, id DESC").
|
||||||
|
First(&pricelist).Error; err != nil {
|
||||||
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
||||||
}
|
}
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
|
|||||||
@@ -126,6 +126,101 @@ func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetLatestActiveBySource_SkipsPricelistsWithoutItems(t *testing.T) {
|
||||||
|
repo := newTestPricelistRepository(t)
|
||||||
|
db := repo.db
|
||||||
|
ts := time.Now().Add(-time.Minute)
|
||||||
|
source := "test-estimate-skip-empty"
|
||||||
|
|
||||||
|
emptyLatest := models.Pricelist{
|
||||||
|
Source: source,
|
||||||
|
Version: "E-empty",
|
||||||
|
CreatedBy: "test",
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: ts.Add(2 * time.Second),
|
||||||
|
}
|
||||||
|
if err := db.Create(&emptyLatest).Error; err != nil {
|
||||||
|
t.Fatalf("create empty pricelist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
withItems := models.Pricelist{
|
||||||
|
Source: source,
|
||||||
|
Version: "E-with-items",
|
||||||
|
CreatedBy: "test",
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: ts,
|
||||||
|
}
|
||||||
|
if err := db.Create(&withItems).Error; err != nil {
|
||||||
|
t.Fatalf("create pricelist with items: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.PricelistItem{
|
||||||
|
PricelistID: withItems.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
Price: 100,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create pricelist item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := repo.GetLatestActiveBySource(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestActiveBySource: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != withItems.ID {
|
||||||
|
t.Fatalf("expected pricelist with items id=%d, got id=%d", withItems.ID, got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLatestActiveBySource_TieBreaksByID(t *testing.T) {
|
||||||
|
repo := newTestPricelistRepository(t)
|
||||||
|
db := repo.db
|
||||||
|
ts := time.Now().Add(-time.Minute)
|
||||||
|
source := "test-warehouse-tie-break"
|
||||||
|
|
||||||
|
first := models.Pricelist{
|
||||||
|
Source: source,
|
||||||
|
Version: "S-1",
|
||||||
|
CreatedBy: "test",
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: ts,
|
||||||
|
}
|
||||||
|
if err := db.Create(&first).Error; err != nil {
|
||||||
|
t.Fatalf("create first pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.PricelistItem{
|
||||||
|
PricelistID: first.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
Price: 101,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create first item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := models.Pricelist{
|
||||||
|
Source: source,
|
||||||
|
Version: "S-2",
|
||||||
|
CreatedBy: "test",
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: ts,
|
||||||
|
}
|
||||||
|
if err := db.Create(&second).Error; err != nil {
|
||||||
|
t.Fatalf("create second pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.PricelistItem{
|
||||||
|
PricelistID: second.ID,
|
||||||
|
LotName: "CPU_A",
|
||||||
|
Price: 102,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create second item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := repo.GetLatestActiveBySource(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestActiveBySource: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != second.ID {
|
||||||
|
t.Fatalf("expected later inserted pricelist id=%d, got id=%d", second.ID, got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -433,6 +433,11 @@ let selectedPricelistIds = {
|
|||||||
warehouse: null,
|
warehouse: null,
|
||||||
competitor: null
|
competitor: null
|
||||||
};
|
};
|
||||||
|
let resolvedAutoPricelistIds = {
|
||||||
|
estimate: null,
|
||||||
|
warehouse: null,
|
||||||
|
competitor: null
|
||||||
|
};
|
||||||
let disablePriceRefresh = false;
|
let disablePriceRefresh = false;
|
||||||
let onlyInStock = false;
|
let onlyInStock = false;
|
||||||
let activePricelistsBySource = {
|
let activePricelistsBySource = {
|
||||||
@@ -498,6 +503,22 @@ function formatDelta(abs, pct) {
|
|||||||
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEffectivePricelistID(source) {
|
||||||
|
const explicit = selectedPricelistIds[source];
|
||||||
|
if (Number.isFinite(explicit) && explicit > 0) {
|
||||||
|
return Number(explicit);
|
||||||
|
}
|
||||||
|
const resolvedAuto = resolvedAutoPricelistIds[source];
|
||||||
|
if (Number.isFinite(resolvedAuto) && resolvedAuto > 0) {
|
||||||
|
return Number(resolvedAuto);
|
||||||
|
}
|
||||||
|
const fallback = activePricelistsBySource[source]?.[0]?.id;
|
||||||
|
if (Number.isFinite(fallback) && fallback > 0) {
|
||||||
|
return Number(fallback);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshPriceLevels(options = {}) {
|
async function refreshPriceLevels(options = {}) {
|
||||||
const force = options.force === true;
|
const force = options.force === true;
|
||||||
const noCache = options.noCache === true;
|
const noCache = options.noCache === true;
|
||||||
@@ -543,12 +564,10 @@ async function refreshPriceLevels(options = {}) {
|
|||||||
if (data.resolved_pricelist_ids) {
|
if (data.resolved_pricelist_ids) {
|
||||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||||
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
|
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
|
||||||
selectedPricelistIds[source] = data.resolved_pricelist_ids[source];
|
resolvedAutoPricelistIds[source] = Number(data.resolved_pricelist_ids[source]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
syncPriceSettingsControls();
|
|
||||||
renderPricelistSettingsSummary();
|
renderPricelistSettingsSummary();
|
||||||
persistLocalPriceSettings();
|
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Failed to refresh price levels', e);
|
console.error('Failed to refresh price levels', e);
|
||||||
@@ -581,11 +600,7 @@ function schedulePriceLevelsRefresh(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentWarehousePricelistID() {
|
function currentWarehousePricelistID() {
|
||||||
const id = selectedPricelistIds.warehouse;
|
return getEffectivePricelistID('warehouse');
|
||||||
if (Number.isFinite(id) && id > 0) return Number(id);
|
|
||||||
const fallback = activePricelistsBySource.warehouse?.[0]?.id;
|
|
||||||
if (Number.isFinite(fallback) && fallback > 0) return Number(fallback);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWarehouseInStockLots() {
|
async function loadWarehouseInStockLots() {
|
||||||
@@ -823,9 +838,7 @@ async function loadActivePricelists(force = false) {
|
|||||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
|
selectedPricelistIds[source] = null;
|
||||||
? Number(activePricelistsBySource[source][0].id)
|
|
||||||
: null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
activePricelistsBySource[source] = [];
|
activePricelistsBySource[source] = [];
|
||||||
selectedPricelistIds[source] = null;
|
selectedPricelistIds[source] = null;
|
||||||
@@ -961,6 +974,15 @@ function applyPriceSettings() {
|
|||||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
||||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
||||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
||||||
|
if (selectedPricelistIds.estimate) {
|
||||||
|
resolvedAutoPricelistIds.estimate = null;
|
||||||
|
}
|
||||||
|
if (selectedPricelistIds.warehouse) {
|
||||||
|
resolvedAutoPricelistIds.warehouse = null;
|
||||||
|
}
|
||||||
|
if (selectedPricelistIds.competitor) {
|
||||||
|
resolvedAutoPricelistIds.competitor = null;
|
||||||
|
}
|
||||||
disablePriceRefresh = disableVal;
|
disablePriceRefresh = disableVal;
|
||||||
onlyInStock = inStockVal;
|
onlyInStock = inStockVal;
|
||||||
|
|
||||||
@@ -1861,7 +1883,8 @@ async function previewArticle() {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const model = serverModelForQuote.trim();
|
const model = serverModelForQuote.trim();
|
||||||
if (!model || !selectedPricelistIds.estimate || cart.length === 0) {
|
const estimatePricelistID = getEffectivePricelistID('estimate');
|
||||||
|
if (!model || !estimatePricelistID || cart.length === 0) {
|
||||||
currentArticle = '';
|
currentArticle = '';
|
||||||
el.textContent = 'Артикул: —';
|
el.textContent = 'Артикул: —';
|
||||||
return;
|
return;
|
||||||
@@ -1874,7 +1897,7 @@ async function previewArticle() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
server_model: serverModelForQuote,
|
server_model: serverModelForQuote,
|
||||||
support_code: supportCode,
|
support_code: supportCode,
|
||||||
pricelist_id: selectedPricelistIds.estimate,
|
pricelist_id: estimatePricelistID,
|
||||||
items: cart.map(item => ({
|
items: cart.map(item => ({
|
||||||
lot_name: item.lot_name,
|
lot_name: item.lot_name,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
@@ -2408,13 +2431,19 @@ async function refreshPrices() {
|
|||||||
updatePriceUpdateDate(config.price_updated_at);
|
updatePriceUpdateDate(config.price_updated_at);
|
||||||
}
|
}
|
||||||
if (config.pricelist_id) {
|
if (config.pricelist_id) {
|
||||||
selectedPricelistIds.estimate = config.pricelist_id;
|
if (selectedPricelistIds.estimate) {
|
||||||
|
selectedPricelistIds.estimate = config.pricelist_id;
|
||||||
|
} else {
|
||||||
|
resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
|
||||||
|
}
|
||||||
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
||||||
await loadActivePricelists();
|
await loadActivePricelists();
|
||||||
}
|
}
|
||||||
syncPriceSettingsControls();
|
syncPriceSettingsControls();
|
||||||
renderPricelistSettingsSummary();
|
renderPricelistSettingsSummary();
|
||||||
persistLocalPriceSettings();
|
if (selectedPricelistIds.estimate) {
|
||||||
|
persistLocalPriceSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-render UI
|
// Re-render UI
|
||||||
|
|||||||
Reference in New Issue
Block a user