Fix auto pricelist resolution and latest-price selection; update Bible

This commit is contained in:
Mikhail Chusavitin
2026-02-20 19:15:24 +03:00
parent 7f8491d197
commit 3c46cd7bf0
11 changed files with 419 additions and 28 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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 |

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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

View 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)
}
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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