8 Commits

Author SHA1 Message Date
Mikhail Chusavitin
8b5e04168a release: v1.8 2026-04-28 16:56:45 +03:00
Mikhail Chusavitin
61d23ef8c4 Fix pricelist sync upsert and refresh tests 2026-04-28 16:54:36 +03:00
11fd314a65 release: v1.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:03:17 +03:00
e59a43c279 feat: унифицировать autocomplete для LOT на всех вкладках
- Все вкладки (storage, pci, power, accessories, sw, other) теперь
  используют редактируемый autocomplete-input для существующих позиций,
  как на вкладке base; выбор заменяет позицию с сохранением количества
- LOT-поле в BOM-таблицах переведено на общий autocomplete dropdown
  вместо datalist
- Кнопка ✕ в BOM снимает сопоставление вместо удаления строки
- Кнопка «Пересчитать эстимейт» переименована в «Перенести в эстимейт»

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:00:45 +03:00
Mikhail Chusavitin
83a3202bdf Restore RAID section for server storage tab 2026-04-16 09:28:14 +03:00
Mikhail Chusavitin
4bc7979a70 Remove obsolete storage components guide docx 2026-04-15 18:58:10 +03:00
Mikhail Chusavitin
1137c6d4db Persist pricing state and refresh storage sync 2026-04-15 18:56:40 +03:00
Mikhail Chusavitin
7e1e2ac18d Fix storage sync and configurator category visibility 2026-04-15 18:40:34 +03:00
11 changed files with 613 additions and 373 deletions

View File

@@ -29,17 +29,15 @@ Rules:
## MariaDB ## MariaDB
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-03-21. MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
### QuoteForge tables (qt_* and stock_*) ### QuoteForge tables (qt_*)
Runtime read: Runtime read:
- `qt_categories` — pricelist categories - `qt_categories` — pricelist categories
- `qt_lot_metadata` — component metadata, price settings - `qt_lot_metadata` — component metadata, price settings
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor) - `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
- `qt_pricelist_items` — pricelist rows - `qt_pricelist_items` — pricelist rows
- `stock_log` — raw supplier price log, source for pricelist generation
- `stock_ignore_rules` — patterns to skip during stock import
- `qt_partnumber_books` — partnumber book headers - `qt_partnumber_books` — partnumber book headers
- `qt_partnumber_book_items` — PN→LOT catalog payload - `qt_partnumber_book_items` — PN→LOT catalog payload
@@ -69,18 +67,20 @@ QuoteForge references competitor pricelists only via `qt_pricelists` (source='co
### Legacy RFQ tables (pre-QuoteForge, no Go code references) ### Legacy RFQ tables (pre-QuoteForge, no Go code references)
- `lot` — original component registry (data preserved; superseded by `qt_lot_metadata`) - `lot` — original component registry (data preserved; superseded by `qt_lot_metadata`)
- `lot_log` — original supplier price log (superseded by `stock_log`) - `lot_log` — original supplier price log
- `supplier` — supplier registry (FK target for lot_log and machine_log) - `supplier` — supplier registry (FK target for lot_log and machine_log)
- `machine` — device model registry - `machine` — device model registry
- `machine_log` — device price/quote log - `machine_log` — device price/quote log
- `parts_log` — supplier partnumber log used by server-side import/pricing workflows, not by QuoteForge runtime
These tables are retained for historical data. QuoteForge does not read or write them at runtime. These tables are retained for historical data. QuoteForge does not read or write them at runtime.
Rules: Rules:
- QuoteForge runtime must not depend on any legacy RFQ tables; - QuoteForge runtime must not depend on any legacy RFQ tables;
- stock enrichment happens during sync and is persisted into SQLite; - QuoteForge sync reads prices and categories from `qt_pricelists` / `qt_pricelist_items` only;
- QuoteForge does not enrich local pricelist rows from `parts_log` or any other raw supplier log table;
- normal UI requests must not query MariaDB tables directly; - normal UI requests must not query MariaDB tables directly;
- `qt_client_local_migrations` was removed from the schema on 2026-03-21 (was in earlier drafts). - `qt_client_local_migrations` exists in the 2026-04-15 schema dump, but runtime sync does not depend on it.
## MariaDB Table Structures ## MariaDB Table Structures

Binary file not shown.

View File

@@ -28,8 +28,9 @@ type ComponentSyncResult struct {
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) { func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
startTime := time.Now() startTime := time.Now()
// Query to join lot with qt_lot_metadata (metadata only, no pricing) // Build the component catalog from every runtime source of LOT names.
// Use LEFT JOIN to include lots without metadata // Storage lots may exist in qt_lot_metadata / qt_pricelist_items before they appear in lot,
// so the sync cannot start from lot alone.
type componentRow struct { type componentRow struct {
LotName string LotName string
LotDescription string LotDescription string
@@ -40,15 +41,29 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
var rows []componentRow var rows []componentRow
err := mariaDB.Raw(` err := mariaDB.Raw(`
SELECT SELECT
l.lot_name, src.lot_name,
l.lot_description, COALESCE(MAX(NULLIF(TRIM(l.lot_description), '')), '') AS lot_description,
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category, COALESCE(
m.model MAX(NULLIF(TRIM(c.code), '')),
FROM lot l MAX(NULLIF(TRIM(l.lot_category), '')),
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name SUBSTRING_INDEX(src.lot_name, '_', 1)
) AS category,
MAX(NULLIF(TRIM(m.model), '')) AS model
FROM (
SELECT lot_name FROM lot
UNION
SELECT lot_name FROM qt_lot_metadata
WHERE is_hidden = FALSE OR is_hidden IS NULL
UNION
SELECT lot_name FROM qt_pricelist_items
) src
LEFT JOIN lot l ON l.lot_name = src.lot_name
LEFT JOIN qt_lot_metadata m
ON m.lot_name = src.lot_name
AND (m.is_hidden = FALSE OR m.is_hidden IS NULL)
LEFT JOIN qt_categories c ON m.category_id = c.id LEFT JOIN qt_categories c ON m.category_id = c.id
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL GROUP BY src.lot_name
ORDER BY l.lot_name ORDER BY src.lot_name
`).Scan(&rows).Error `).Scan(&rows).Error
if err != nil { if err != nil {
return nil, fmt.Errorf("querying components from MariaDB: %w", err) return nil, fmt.Errorf("querying components from MariaDB: %w", err)
@@ -71,18 +86,25 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
existingMap[c.LotName] = true existingMap[c.LotName] = true
} }
// Prepare components for batch insert/update // Prepare components for batch insert/update.
// Source joins may duplicate the same lot_name, so collapse them before insert.
syncTime := time.Now() syncTime := time.Now()
components := make([]LocalComponent, 0, len(rows)) components := make([]LocalComponent, 0, len(rows))
componentIndex := make(map[string]int, len(rows))
newCount := 0 newCount := 0
for _, row := range rows { for _, row := range rows {
lotName := strings.TrimSpace(row.LotName)
if lotName == "" {
continue
}
category := "" category := ""
if row.Category != nil { if row.Category != nil {
category = *row.Category category = strings.TrimSpace(*row.Category)
} else { } else {
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU") // Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
parts := strings.SplitN(row.LotName, "_", 2) parts := strings.SplitN(lotName, "_", 2)
if len(parts) >= 1 { if len(parts) >= 1 {
category = parts[0] category = parts[0]
} }
@@ -90,18 +112,34 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
model := "" model := ""
if row.Model != nil { if row.Model != nil {
model = *row.Model model = strings.TrimSpace(*row.Model)
} }
comp := LocalComponent{ comp := LocalComponent{
LotName: row.LotName, LotName: lotName,
LotDescription: row.LotDescription, LotDescription: strings.TrimSpace(row.LotDescription),
Category: category, Category: category,
Model: model, Model: model,
} }
if idx, exists := componentIndex[lotName]; exists {
// Keep the first row, but fill any missing metadata from duplicates.
if components[idx].LotDescription == "" && comp.LotDescription != "" {
components[idx].LotDescription = comp.LotDescription
}
if components[idx].Category == "" && comp.Category != "" {
components[idx].Category = comp.Category
}
if components[idx].Model == "" && comp.Model != "" {
components[idx].Model = comp.Model
}
continue
}
componentIndex[lotName] = len(components)
components = append(components, comp) components = append(components, comp)
if !existingMap[row.LotName] { if !existingMap[lotName] {
newCount++ newCount++
} }
} }

View File

@@ -499,7 +499,7 @@ func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read summary row: %v", err) t.Fatalf("read summary row: %v", err)
} }
expectedSummary := []string{"10", "", "", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"} expectedSummary := []string{"10", "", "ART-1", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
for i, want := range expectedSummary { for i, want := range expectedSummary {
if summary[i] != want { if summary[i] != want {
t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i]) t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i])

View File

@@ -17,6 +17,7 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
var ErrOffline = errors.New("database is offline") var ErrOffline = errors.New("database is offline")
@@ -357,6 +358,18 @@ func (s *Service) SyncPricelists() (int, error) {
// Check if pricelist already exists locally // Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil { if existing != nil {
existing.Source = pl.Source
existing.Version = pl.Version
existing.Name = pl.Notification
existing.CreatedAt = pl.CreatedAt
existing.SyncedAt = time.Now()
if err := s.localDB.SaveLocalPricelist(existing); err != nil {
if syncErr == nil {
syncErr = fmt.Errorf("refresh existing pricelist %s: %w", pl.Version, err)
}
slog.Warn("failed to refresh existing local pricelist header", "version", pl.Version, "error", err)
continue
}
// Backfill items for legacy/partial local caches where only pricelist metadata exists. // Backfill items for legacy/partial local caches where only pricelist metadata exists.
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 { if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
itemCount, err := s.SyncPricelistItems(existing.ID) itemCount, err := s.SyncPricelistItems(existing.ID)
@@ -468,25 +481,30 @@ func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int
} }
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error { if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
if err := tx.Create(localPL).Error; err != nil { if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "server_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"source": localPL.Source,
"version": localPL.Version,
"name": localPL.Name,
"created_at": localPL.CreatedAt,
"synced_at": localPL.SyncedAt,
"is_used": localPL.IsUsed,
}),
}).Create(localPL).Error; err != nil {
return fmt.Errorf("save local pricelist: %w", err) return fmt.Errorf("save local pricelist: %w", err)
} }
if len(localItems) == 0 { if localPL.ID == 0 {
return nil if err := tx.Where("server_id = ?", localPL.ServerID).First(localPL).Error; err != nil {
return fmt.Errorf("reload local pricelist: %w", err)
}
} }
for i := range localItems { for i := range localItems {
localItems[i].PricelistID = localPL.ID localItems[i].PricelistID = localPL.ID
} }
batchSize := 500 if err := replaceLocalPricelistItemsTx(tx, localPL.ID, localItems); err != nil {
for i := 0; i < len(localItems); i += batchSize {
end := i + batchSize
if end > len(localItems) {
end = len(localItems)
}
if err := tx.CreateInBatches(localItems[i:end], batchSize).Error; err != nil {
return fmt.Errorf("save local pricelist items: %w", err) return fmt.Errorf("save local pricelist items: %w", err)
} }
}
return nil return nil
}); err != nil { }); err != nil {
return 0, err return 0, err
@@ -496,6 +514,27 @@ func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int
return len(localItems), nil return len(localItems), nil
} }
func replaceLocalPricelistItemsTx(tx *gorm.DB, pricelistID uint, items []localdb.LocalPricelistItem) error {
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&localdb.LocalPricelistItem{}).Error; err != nil {
return err
}
if len(items) == 0 {
return nil
}
batchSize := 500
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil {
return err
}
}
return nil
}
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) { func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
if s.localDB == nil || pricelistRepo == nil { if s.localDB == nil || pricelistRepo == nil {
return return
@@ -789,9 +828,6 @@ func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.L
for i, item := range serverItems { for i, item := range serverItems {
localItems[i] = *localdb.PricelistItemToLocal(&item, 0) localItems[i] = *localdb.PricelistItemToLocal(&item, 0)
} }
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
slog.Warn("pricelist stock enrichment skipped", "server_pricelist_id", serverPricelistID, "error", err)
}
return localItems, nil return localItems, nil
} }
@@ -805,111 +841,6 @@ func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, err
return s.SyncPricelistItems(localPL.ID) return s.SyncPricelistItems(localPL.ID)
} }
func (s *Service) enrichLocalPricelistItemsWithStock(mariaDB *gorm.DB, items []localdb.LocalPricelistItem) error {
if len(items) == 0 {
return nil
}
bookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
book, err := bookRepo.GetActiveBook()
if err != nil || book == nil {
return nil
}
bookItems, err := bookRepo.GetBookItems(book.ID)
if err != nil {
return err
}
if len(bookItems) == 0 {
return nil
}
partnumberToLots := make(map[string][]string, len(bookItems))
for _, item := range bookItems {
pn := strings.TrimSpace(item.Partnumber)
if pn == "" {
continue
}
seenLots := make(map[string]struct{}, len(item.LotsJSON))
for _, lot := range item.LotsJSON {
lotName := strings.TrimSpace(lot.LotName)
if lotName == "" {
continue
}
key := strings.ToLower(lotName)
if _, exists := seenLots[key]; exists {
continue
}
seenLots[key] = struct{}{}
partnumberToLots[pn] = append(partnumberToLots[pn], lotName)
}
}
if len(partnumberToLots) == 0 {
return nil
}
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Qty *float64 `gorm:"column:qty"`
}
rows := make([]stockRow, 0)
if err := mariaDB.Raw(`
SELECT s.partnumber, s.qty
FROM stock_log s
INNER JOIN (
SELECT partnumber, MAX(date) AS max_date
FROM stock_log
GROUP BY partnumber
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
WHERE s.qty IS NOT NULL
`).Scan(&rows).Error; err != nil {
return err
}
lotTotals := make(map[string]float64, len(items))
lotPartnumbers := make(map[string][]string, len(items))
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
for _, row := range rows {
pn := strings.TrimSpace(row.Partnumber)
if pn == "" || row.Qty == nil {
continue
}
lots := partnumberToLots[pn]
if len(lots) == 0 {
continue
}
for _, lotName := range lots {
lotTotals[lotName] += *row.Qty
if _, ok := seenPartnumbers[lotName]; !ok {
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
}
key := strings.ToLower(pn)
if _, exists := seenPartnumbers[lotName][key]; exists {
continue
}
seenPartnumbers[lotName][key] = struct{}{}
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
}
}
for i := range items {
lotName := strings.TrimSpace(items[i].LotName)
if qty, ok := lotTotals[lotName]; ok {
qtyCopy := qty
items[i].AvailableQty = &qtyCopy
}
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
sort.Slice(partnumbers, func(a, b int) bool {
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
})
items[i].Partnumbers = append(localdb.LocalStringList{}, partnumbers...)
}
}
return nil
}
// GetLocalPriceForLot returns the price for a lot from a local pricelist // GetLocalPriceForLot returns the price for a lot from a local pricelist
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) { func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName) return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)

View File

@@ -17,7 +17,6 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
&models.Pricelist{}, &models.Pricelist{},
&models.PricelistItem{}, &models.PricelistItem{},
&models.Lot{}, &models.Lot{},
&models.StockLog{},
); err != nil { ); err != nil {
t.Fatalf("migrate server tables: %v", err) t.Fatalf("migrate server tables: %v", err)
} }
@@ -103,103 +102,3 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory) t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
} }
} }
func TestSyncPricelistItems_EnrichesStockFromLocalPartnumberBook(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
if err := serverDB.AutoMigrate(
&models.Pricelist{},
&models.PricelistItem{},
&models.Lot{},
&models.StockLog{},
); err != nil {
t.Fatalf("migrate server tables: %v", err)
}
serverPL := models.Pricelist{
Source: "warehouse",
Version: "2026-03-07-001",
Notification: "server",
CreatedBy: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := serverDB.Create(&serverPL).Error; err != nil {
t.Fatalf("create server pricelist: %v", err)
}
if err := serverDB.Create(&models.PricelistItem{
PricelistID: serverPL.ID,
LotName: "CPU_A",
LotCategory: "CPU",
Price: 10,
}).Error; err != nil {
t.Fatalf("create server pricelist item: %v", err)
}
qty := 7.0
if err := serverDB.Create(&models.StockLog{
Partnumber: "CPU-PN-1",
Date: time.Now(),
Price: 100,
Qty: &qty,
}).Error; err != nil {
t.Fatalf("create stock log: %v", err)
}
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: serverPL.ID,
Source: serverPL.Source,
Version: serverPL.Version,
Name: serverPL.Notification,
CreatedAt: serverPL.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}); err != nil {
t.Fatalf("seed local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.DB().Create(&localdb.LocalPartnumberBook{
ServerID: 1,
Version: "2026-03-07-001",
CreatedAt: time.Now(),
IsActive: true,
PartnumbersJSON: localdb.LocalStringList{"CPU-PN-1"},
}).Error; err != nil {
t.Fatalf("create local partnumber book: %v", err)
}
if err := local.DB().Create(&localdb.LocalPartnumberBookItem{
Partnumber: "CPU-PN-1",
LotsJSON: localdb.LocalPartnumberBookLots{
{LotName: "CPU_A", Qty: 1},
},
Description: "CPU PN",
}).Error; err != nil {
t.Fatalf("create local partnumber book item: %v", err)
}
svc := syncsvc.NewServiceWithDB(serverDB, local)
if _, err := svc.SyncPricelistItems(localPL.ID); err != nil {
t.Fatalf("sync pricelist items: %v", err)
}
items, err := local.GetLocalPricelistItems(localPL.ID)
if err != nil {
t.Fatalf("load local items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 local item, got %d", len(items))
}
if items[0].AvailableQty == nil {
t.Fatalf("expected available_qty to be set")
}
if *items[0].AvailableQty != 7 {
t.Fatalf("expected available_qty=7, got %v", *items[0].AvailableQty)
}
if len(items[0].Partnumbers) != 1 || items[0].Partnumbers[0] != "CPU-PN-1" {
t.Fatalf("expected partnumbers [CPU-PN-1], got %v", items[0].Partnumbers)
}
}

View File

@@ -0,0 +1,118 @@
package sync
import (
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestSyncNewPricelistSnapshotUpsertsExistingServerID(t *testing.T) {
local := newLocalDBForUpsertTest(t)
serverDB := newServerDBForUpsertTest(t)
if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
t.Fatalf("migrate server pricelist tables: %v", err)
}
serverPL := models.Pricelist{
Source: "estimate",
Version: "B-2026-04-28-001",
Notification: "server-current",
CreatedBy: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := serverDB.Create(&serverPL).Error; err != nil {
t.Fatalf("create server pricelist: %v", err)
}
if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil {
t.Fatalf("create server pricelist item: %v", err)
}
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: serverPL.ID,
Source: "estimate",
Version: "old-version",
Name: "stale-local",
CreatedAt: time.Now().Add(-24 * time.Hour),
SyncedAt: time.Now().Add(-24 * time.Hour),
IsUsed: false,
}); err != nil {
t.Fatalf("seed stale local pricelist: %v", err)
}
staleLocal, err := local.GetLocalPricelistByServerID(serverPL.ID)
if err != nil {
t.Fatalf("get stale local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: staleLocal.ID, LotName: "OLD_LOT", Price: 99},
}); err != nil {
t.Fatalf("seed stale local pricelist items: %v", err)
}
svc := NewServiceWithDB(serverDB, local)
localPL := &localdb.LocalPricelist{
ServerID: serverPL.ID,
Source: serverPL.Source,
Version: serverPL.Version,
Name: serverPL.Notification,
CreatedAt: serverPL.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}
itemCount, err := svc.syncNewPricelistSnapshot(localPL)
if err != nil {
t.Fatalf("sync new pricelist snapshot: %v", err)
}
if itemCount != 1 {
t.Fatalf("expected 1 synced item, got %d", itemCount)
}
refreshed, err := local.GetLocalPricelistByServerID(serverPL.ID)
if err != nil {
t.Fatalf("get refreshed local pricelist: %v", err)
}
if refreshed.Version != serverPL.Version {
t.Fatalf("expected local version %q, got %q", serverPL.Version, refreshed.Version)
}
if refreshed.Name != serverPL.Notification {
t.Fatalf("expected local name %q, got %q", serverPL.Notification, refreshed.Name)
}
items, err := local.GetLocalPricelistItems(refreshed.ID)
if err != nil {
t.Fatalf("load refreshed local items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 local item after refresh, got %d", len(items))
}
if items[0].LotName != "CPU_A" {
t.Fatalf("expected refreshed item CPU_A, got %q", items[0].LotName)
}
}
func newLocalDBForUpsertTest(t *testing.T) *localdb.LocalDB {
t.Helper()
localPath := filepath.Join(t.TempDir(), "local.db")
local, err := localdb.New(localPath)
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
return local
}
func newServerDBForUpsertTest(t *testing.T) *gorm.DB {
t.Helper()
serverPath := filepath.Join(t.TempDir(), "server.db")
db, err := gorm.Open(sqlite.Open(serverPath), &gorm.Config{})
if err != nil {
t.Fatalf("open server sqlite: %v", err)
}
return db
}

View File

@@ -434,54 +434,14 @@ func newServerDBForSyncTest(t *testing.T) *gorm.DB {
if err != nil { if err != nil {
t.Fatalf("open server sqlite: %v", err) t.Fatalf("open server sqlite: %v", err)
} }
if err := db.Exec(` if err := db.AutoMigrate(
CREATE TABLE qt_projects ( &models.Project{},
id INTEGER PRIMARY KEY AUTOINCREMENT, &models.Configuration{},
uuid TEXT NOT NULL UNIQUE, &models.Pricelist{},
owner_username TEXT NOT NULL, &models.PricelistItem{},
code TEXT NOT NULL, &models.Lot{},
variant TEXT NOT NULL DEFAULT '', ); err != nil {
name TEXT NOT NULL, t.Fatalf("migrate server test schema: %v", err)
tracker_url TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
is_system INTEGER NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
);`).Error; err != nil {
t.Fatalf("create qt_projects: %v", err)
}
if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil {
t.Fatalf("create qt_projects index: %v", err)
}
if err := db.Exec(`
CREATE TABLE qt_configurations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
user_id INTEGER NULL,
owner_username TEXT NOT NULL,
project_uuid TEXT NULL,
app_version TEXT NULL,
name TEXT NOT NULL,
items TEXT NOT NULL,
total_price REAL NULL,
custom_price REAL NULL,
notes TEXT NULL,
is_template INTEGER NOT NULL DEFAULT 0,
server_count INTEGER NOT NULL DEFAULT 1,
server_model TEXT NULL,
support_code TEXT NULL,
article TEXT NULL,
pricelist_id INTEGER NULL,
warehouse_pricelist_id INTEGER NULL,
competitor_pricelist_id INTEGER NULL,
disable_price_refresh INTEGER NOT NULL DEFAULT 0,
only_in_stock INTEGER NOT NULL DEFAULT 0,
line_no INTEGER NULL,
price_updated_at DATETIME NULL,
vendor_spec TEXT NULL,
created_at DATETIME
);`).Error; err != nil {
t.Fatalf("create qt_configurations: %v", err)
} }
return db return db
} }

View File

@@ -0,0 +1,13 @@
# QuoteForge v1.7
Дата релиза: 2026-04-23
Тег: `v1.7`
Предыдущий релиз: `v1.6.2`
## Ключевые изменения
- все вкладки estimate (storage, pci, power, accessories, sw, other) теперь используют редактируемый autocomplete-input для существующих позиций — поведение идентично вкладке base;
- LOT-поля в BOM-таблицах переведены на общий autocomplete dropdown вместо datalist;
- кнопка ✕ в BOM снимает сопоставление BOM→LOT вместо удаления строки;
- кнопка «Пересчитать эстимейт» переименована в «Перенести в эстимейт».

View File

@@ -0,0 +1,13 @@
# QuoteForge v1.8
Дата релиза: 2026-04-28
Тег: `v1.8`
Предыдущий релиз: `v1.7`
## Ключевые изменения
- исправлен sync прайслистов при конфликте `local_pricelists.server_id`: сохранение локального снапшота стало idempotent через upsert;
- сохранение нового локального снапшота прайслиста теперь атомарно заменяет строки внутри одной транзакции;
- sync обновляет метаданные уже существующих локальных прайслистов;
- устаревшие sync/export тесты приведены к актуальному контракту, `go test ./...` проходит полностью.

View File

@@ -194,7 +194,7 @@
Сохранить BOM Сохранить BOM
</button> </button>
<button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700"> <button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700">
Пересчитать эстимейт Перенести в эстимейт
</button> </button>
</div> </div>
</div> </div>
@@ -542,7 +542,8 @@ let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks o
// Autocomplete state // Autocomplete state
let autocompleteInput = null; let autocompleteInput = null;
let autocompleteCategory = null; let autocompleteCategory = null;
let autocompleteMode = null; // 'single', 'multi', 'section' let autocompleteMode = null; // 'single', 'multi', 'section', 'edit-item'
let autocompleteEditCategories = null;
let autocompleteIndex = -1; let autocompleteIndex = -1;
let autocompleteFiltered = []; let autocompleteFiltered = [];
@@ -837,6 +838,7 @@ document.addEventListener('DOMContentLoaded', async function() {
serverModelForQuote = config.server_model || ''; serverModelForQuote = config.server_model || '';
supportCode = config.support_code || ''; supportCode = config.support_code || '';
currentArticle = config.article || ''; currentArticle = config.article || '';
restorePricingStateFromNotes(config.notes || '');
// Restore custom price if saved // Restore custom price if saved
if (config.custom_price) { if (config.custom_price) {
@@ -1155,17 +1157,59 @@ function switchTab(tab) {
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']); const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
// Storage-only categories — hidden for server configs // Storage-only categories — hidden for server configs
const STORAGE_ONLY_BASE_CATEGORIES = ['ENC', 'DKC', 'CTL']; const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
// Server-only categories — hidden for storage configs
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID'];
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
function applyConfigTypeToTabs() { function applyConfigTypeToTabs() {
if (configType === 'storage') return; // storage sees everything const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
// Remove ENC/DKC/CTL from Base const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
TAB_CONFIG.base.categories = TAB_CONFIG.base.categories.filter( const storageSections = [
c => !STORAGE_ONLY_BASE_CATEGORIES.includes(c) { title: 'RAID Контроллеры', categories: ['RAID'] },
); { title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
// Remove HIC from PCI tab ];
TAB_CONFIG.pci.categories = TAB_CONFIG.pci.categories.filter(c => c !== 'HIC'); const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
TAB_CONFIG.pci.sections = TAB_CONFIG.pci.sections.filter(s => s.title !== 'HIC'); const pciSections = [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
{ title: 'HBA', categories: ['HBA'] },
{ title: 'HIC', categories: ['HIC'] }
];
const powerCategories = ['PS', 'PSU'];
TAB_CONFIG.base.categories = baseCategories.filter(c => {
if (configType === 'storage') {
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
}
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
});
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
});
TAB_CONFIG.storage.sections = storageSections.filter(section => {
if (configType === 'storage') {
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
}
return true;
});
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
});
TAB_CONFIG.pci.sections = pciSections.filter(section => {
if (configType === 'storage') {
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat));
}
return section.title !== 'HIC';
});
TAB_CONFIG.power.categories = powerCategories.filter(c => {
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
});
// Rebuild assigned categories index // Rebuild assigned categories index
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories) .flatMap(t => t.categories)
@@ -1226,7 +1270,7 @@ function renderSingleSelectTab(categories) {
if (currentTab === 'base') { if (currentTab === 'base') {
html += ` html += `
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start"> <div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label> <label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель системы для партномера:</label>
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label> <label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
</div> </div>
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start"> <div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
@@ -1346,7 +1390,16 @@ function renderMultiSelectTab(components) {
html += ` html += `
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td> <td class="px-3 py-2 min-w-48">
<div class="autocomplete-wrapper relative">
<input type="text"
value="${escapeHtml(item.lot_name)}"
class="w-full px-2 py-1 border rounded text-sm font-mono"
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
oninput="filterAutocompleteEditItem(this.value)"
onkeydown="handleAutocompleteKeyEditItem(event)">
</div>
</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td> <td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td> <td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
@@ -1440,6 +1493,10 @@ function renderMultiSelectTabWithSections(sections) {
<tbody class="divide-y"> <tbody class="divide-y">
`; `;
// Add empty row for new item in this section
const sectionId = section.categories.join('-');
const categoriesStr = section.categories.join(',');
// Render existing cart items for this section // Render existing cart items for this section
sectionItems.forEach((item) => { sectionItems.forEach((item) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name); const comp = allComponents.find(c => c.lot_name === item.lot_name);
@@ -1447,7 +1504,17 @@ function renderMultiSelectTabWithSections(sections) {
html += ` html += `
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td> <td class="px-3 py-2 min-w-48">
<div class="autocomplete-wrapper relative">
<input type="text"
value="${escapeHtml(item.lot_name)}"
data-categories="${categoriesStr}"
class="w-full px-2 py-1 border rounded text-sm font-mono"
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
oninput="filterAutocompleteEditItem(this.value)"
onkeydown="handleAutocompleteKeyEditItem(event)">
</div>
</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td> <td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td> <td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
@@ -1468,8 +1535,6 @@ function renderMultiSelectTabWithSections(sections) {
}); });
// Add empty row for new item in this section // Add empty row for new item in this section
const sectionId = section.categories.join('-');
const categoriesStr = section.categories.join(',');
html += ` html += `
<tr class="hover:bg-gray-50 bg-gray-50"> <tr class="hover:bg-gray-50 bg-gray-50">
<td class="px-3 py-2" colspan="2"> <td class="px-3 py-2" colspan="2">
@@ -1597,6 +1662,10 @@ function renderAutocomplete() {
onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`; onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`;
} else if (autocompleteMode === 'multi') { } else if (autocompleteMode === 'multi') {
onmousedown = `selectAutocompleteItemMulti(${idx})`; onmousedown = `selectAutocompleteItemMulti(${idx})`;
} else if (autocompleteMode === 'bom') {
onmousedown = `selectAutocompleteItemBOM(${idx}, ${autocompleteCategory})`;
} else if (autocompleteMode === 'edit-item') {
onmousedown = `selectAutocompleteEditItem(${idx})`;
} else { } else {
// single mode // single mode
onmousedown = `selectAutocompleteItem(${idx})`; onmousedown = `selectAutocompleteItem(${idx})`;
@@ -1878,6 +1947,138 @@ function selectAutocompleteItemSection(index, sectionId) {
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false }); schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
} }
// Autocomplete for editing an existing cart item's LOT (multi/section tabs)
async function showAutocompleteEditItem(lotName, input) {
autocompleteInput = input;
autocompleteCategory = lotName;
autocompleteMode = 'edit-item';
autocompleteIndex = -1;
autocompleteEditCategories = input.dataset.categories
? input.dataset.categories.split(',').map(c => c.trim().toUpperCase())
: null;
const components = getComponentsForTab(currentTab);
await ensurePricesLoaded(components);
filterAutocompleteEditItem(input.value);
}
function filterAutocompleteEditItem(search) {
const searchLower = (search || '').toLowerCase();
const components = autocompleteEditCategories
? allComponents.filter(c => autocompleteEditCategories.includes(getComponentCategory(c)))
: getComponentsForTab(currentTab);
autocompleteFiltered = components.filter(c => {
if (!hasComponentPrice(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
return (c.lot_name + ' ' + (c.description || '')).toLowerCase().includes(searchLower);
}).sort((a, b) => {
const d = (b.popularity_score || 0) - (a.popularity_score || 0);
return d !== 0 ? d : a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeyEditItem(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteEditItem(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteEditItem(index) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const lotName = autocompleteCategory;
const oldItem = cart.find(i => i.lot_name === lotName);
const qty = oldItem?.quantity || 1;
cart = cart.filter(i => i.lot_name !== lotName);
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
// Autocomplete for BOM LOT mapping
function showAutocompleteBOM(rowIdx, input) {
autocompleteInput = input;
autocompleteCategory = rowIdx;
autocompleteMode = 'bom';
autocompleteIndex = -1;
filterAutocompleteBOM(rowIdx, input.value);
}
function filterAutocompleteBOM(rowIdx, search) {
const searchLower = (search || '').toLowerCase();
autocompleteFiltered = (window._bomAllComponents || allComponents).filter(c => {
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
}).sort((a, b) => {
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeyBOM(event, rowIdx) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemBOM(autocompleteIndex, rowIdx);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItemBOM(index, rowIdx) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
if (!row) return;
row.manual_lot = comp.lot_name;
hideAutocomplete();
resolveBOM();
}
function clearSingleSelect(category) { function clearSingleSelect(category) {
cart = cart.filter(item => cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase() (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
@@ -2079,6 +2280,58 @@ function getCurrentArticle() {
return currentArticle || ''; return currentArticle || '';
} }
function buildPricingState() {
const buyCustom = parseDecimalInput(document.getElementById('pricing-custom-price-buy')?.value || '');
const saleUplift = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
const saleCustom = parseDecimalInput(document.getElementById('pricing-custom-price-sale')?.value || '');
return {
buy_custom_price: buyCustom > 0 ? buyCustom : null,
sale_uplift: saleUplift > 0 ? saleUplift : null,
sale_custom_price: saleCustom > 0 ? saleCustom : null,
};
}
function serializeConfigNotes() {
return JSON.stringify({
pricing_ui: buildPricingState()
});
}
function restorePricingStateFromNotes(notesRaw) {
if (!notesRaw) return;
let parsed;
try {
parsed = JSON.parse(notesRaw);
} catch (_) {
return;
}
const pricing = parsed?.pricing_ui;
if (!pricing || typeof pricing !== 'object') return;
const buyInput = document.getElementById('pricing-custom-price-buy');
if (buyInput) {
buyInput.value = typeof pricing.buy_custom_price === 'number' && pricing.buy_custom_price > 0
? pricing.buy_custom_price.toFixed(2)
: '';
}
const upliftInput = document.getElementById('pricing-uplift-sale');
if (upliftInput) {
upliftInput.value = typeof pricing.sale_uplift === 'number' && pricing.sale_uplift > 0
? formatUpliftInput(pricing.sale_uplift)
: '';
}
const saleInput = document.getElementById('pricing-custom-price-sale');
if (saleInput) {
saleInput.value = typeof pricing.sale_custom_price === 'number' && pricing.sale_custom_price > 0
? pricing.sale_custom_price.toFixed(2)
: '';
}
}
function getAutosaveStorageKey() { function getAutosaveStorageKey() {
return `qf_config_autosave_${configUUID || 'default'}`; return `qf_config_autosave_${configUUID || 'default'}`;
} }
@@ -2092,7 +2345,7 @@ function buildSavePayload() {
name: configName, name: configName,
items: cart, items: cart,
custom_price: customPrice, custom_price: customPrice,
notes: '', notes: serializeConfigNotes(),
server_count: serverCount, server_count: serverCount,
server_model: serverModelForQuote, server_model: serverModelForQuote,
support_code: supportCode, support_code: supportCode,
@@ -2571,66 +2824,67 @@ async function refreshPrices() {
return; return;
} }
const refreshBtn = document.getElementById('refresh-prices-btn');
const previousLabel = refreshBtn ? refreshBtn.textContent : '';
try { try {
const refreshPayload = {}; if (refreshBtn) {
if (selectedPricelistIds.estimate) { refreshBtn.disabled = true;
refreshPayload.pricelist_id = selectedPricelistIds.estimate; refreshBtn.textContent = 'Обновление...';
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
}
const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' });
if (!componentSyncResp.ok) {
throw new Error('component sync failed');
}
const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' });
if (!pricelistSyncResp.ok) {
throw new Error('pricelist sync failed');
}
await Promise.all([
loadActivePricelists(true),
loadAllComponents()
]);
['estimate', 'warehouse', 'competitor'].forEach(source => {
const latest = activePricelistsBySource[source]?.[0];
if (latest && latest.id) {
selectedPricelistIds[source] = Number(latest.id);
resolvedAutoPricelistIds[source] = null;
} }
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(refreshPayload)
}); });
if (!resp.ok) {
showToast('Ошибка обновления цен', 'error');
return;
}
const config = await resp.json();
// Update cart with new prices
if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price,
estimate_price: item.unit_price,
warehouse_price: null,
competitor_price: null,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
}
// Update price update date
if (config.price_updated_at) {
updatePriceUpdateDate(config.price_updated_at);
}
if (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))) {
await loadActivePricelists();
}
syncPriceSettingsControls(); syncPriceSettingsControls();
renderPricelistSettingsSummary(); renderPricelistSettingsSummary();
if (selectedPricelistIds.estimate) {
persistLocalPriceSettings(); persistLocalPriceSettings();
}
}
// Re-render UI await saveConfig(false);
await refreshPriceLevels({ force: true, noCache: true }); await refreshPriceLevels({ force: true, noCache: true });
renderTab(); renderTab();
updateCartUI(); updateCartUI();
if (configUUID) {
const configResp = await fetch('/api/configs/' + configUUID);
if (configResp.ok) {
const config = await configResp.json();
if (config.price_updated_at) {
updatePriceUpdateDate(config.price_updated_at);
}
}
}
showToast('Цены обновлены', 'success'); showToast('Цены обновлены', 'success');
} catch(e) { } catch(e) {
showToast('Ошибка обновления цен', 'error'); showToast('Ошибка обновления цен', 'error');
} finally {
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.textContent = previousLabel || 'Обновить цены';
updateRefreshPricesButtonState();
}
} }
} }
@@ -2872,6 +3126,18 @@ function deleteBOMRawRow(rowIdx) {
rebuildBOMRowsFromRaw(); rebuildBOMRowsFromRaw();
} }
function clearBOMLotMapping(rowIdx) {
const row = bomRows.find(r => r.source_row_index === rowIdx);
if (!row) return;
row.manual_lot = '';
row.resolved_lot = '';
row.resolution_source = 'unresolved';
row.lot_allocations = [];
row.bundle_enabled = false;
renderBOMTable();
debouncedResolveBOM();
}
function _bomRawLotCell(rowIdx) { function _bomRawLotCell(rowIdx) {
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return '—'; if (!bomImportRaw || bomImportRaw.mode !== 'raw') return '—';
if (bomImportRaw.ignoredRows?.[rowIdx]) return '<span class="text-gray-400">—</span>'; if (bomImportRaw.ignoredRows?.[rowIdx]) return '<span class="text-gray-400">—</span>';
@@ -2893,13 +3159,12 @@ function _bomRawLotCell(rowIdx) {
if (isUnresolved) { if (isUnresolved) {
const val = map.manual_lot || ''; const val = map.manual_lot || '';
const invalid = val && !_bomLotValid(val); return `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
return `<input type="text" placeholder="LOT..." value="${escapeHtml(val)}" value="${escapeHtml(val)}"
class="w-full min-w-28 px-2 py-1 border rounded text-xs ${invalid ? 'border-red-400 bg-red-50' : ''}" class="w-full min-w-28 px-2 py-1 border rounded text-xs font-mono"
list="lot-autocomplete-list" onfocus="showAutocompleteBOM(${rowIdx}, this)"
oninput="setBOMManualLotDraft(${rowIdx}, this.value, this)" oninput="filterAutocompleteBOM(${rowIdx}, this.value); setBOMManualLotDraft(${rowIdx}, this.value, this)"
onchange="commitBOMManualLot(${rowIdx}, this)" onkeydown="handleAutocompleteKeyBOM(event, ${rowIdx})"></div>
onblur="commitBOMManualLot(${rowIdx}, this)">
${renderBOMLotAllocationsEditor(rowIdx)}`; ${renderBOMLotAllocationsEditor(rowIdx)}`;
} }
let suffix = ''; let suffix = '';
@@ -3283,12 +3548,12 @@ function _renderBOMParsedTable() {
let lotCell = ''; let lotCell = '';
if (isUnresolved) { if (isUnresolved) {
lotCell = `<input type="text" placeholder="Введите LOT..." value="${escapeHtml(row.manual_lot || '')}" lotCell = `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
class="w-full px-2 py-1 border rounded text-sm focus:ring-1 focus:ring-blue-400" value="${escapeHtml(row.manual_lot || '')}"
oninput="bomRows[${idx}].manual_lot = this.value; this.classList.toggle('border-red-400', this.value && !_bomLotValid(this.value));" class="w-full px-2 py-1 border rounded text-sm font-mono focus:ring-1 focus:ring-blue-400"
onchange="if(_bomLotValid(this.value)){bomRows[${idx}].manual_lot=this.value;resolveBOM(); this.classList.remove('border-red-400');}else{this.value=bomRows[${idx}].manual_lot||'';}" onfocus="showAutocompleteBOM(${row.source_row_index}, this)"
onblur="if(this.value && !_bomLotValid(this.value)){this.value=bomRows[${idx}].manual_lot||'';}" oninput="filterAutocompleteBOM(${row.source_row_index}, this.value); bomRows.find(r=>r.source_row_index===${row.source_row_index}).manual_lot=this.value;"
list="lot-autocomplete-list">${renderBOMLotAllocationsEditor(idx)}`; onkeydown="handleAutocompleteKeyBOM(event, ${row.source_row_index})"></div>${renderBOMLotAllocationsEditor(idx)}`;
} else { } else {
let suffix = ''; let suffix = '';
if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`; if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`;
@@ -3378,7 +3643,7 @@ function _renderBOMRawTable() {
<td class="w-12 px-1 py-1 border-b text-center align-top whitespace-nowrap"> <td class="w-12 px-1 py-1 border-b text-center align-top whitespace-nowrap">
<button type="button" title="Добавить LOT в bundle" onclick="addBOMAllocation(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-blue-600">+</button> <button type="button" title="Добавить LOT в bundle" onclick="addBOMAllocation(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-blue-600">+</button>
<button type="button" title="${ignored ? 'Не игнорировать' : 'Игнорировать'}" onclick="toggleBOMRawRowIgnored(${rowIdx})" class="inline-block text-xs px-1 ${ignored ? 'text-blue-600' : 'text-gray-400 hover:text-gray-700'}">${ignored ? '◉' : '○'}</button> <button type="button" title="${ignored ? 'Не игнорировать' : 'Игнорировать'}" onclick="toggleBOMRawRowIgnored(${rowIdx})" class="inline-block text-xs px-1 ${ignored ? 'text-blue-600' : 'text-gray-400 hover:text-gray-700'}">${ignored ? '◉' : '○'}</button>
<button type="button" title="Удалить строку" onclick="deleteBOMRawRow(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button> <button type="button" title="Снять сопоставление" onclick="clearBOMLotMapping(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button>
</td>`; </td>`;
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
@@ -4002,14 +4267,17 @@ function applyCustomPrice(table) {
function onBuyCustomPriceInput() { function onBuyCustomPriceInput() {
applyCustomPrice('buy'); applyCustomPrice('buy');
triggerAutoSave();
} }
function onSaleCustomPriceInput() { function onSaleCustomPriceInput() {
applyCustomPrice('sale'); applyCustomPrice('sale');
triggerAutoSave();
} }
function onSaleMarkupInput() { function onSaleMarkupInput() {
renderPricingTab(); renderPricingTab();
triggerAutoSave();
} }
function setPricingCustomPriceFromVendor() { function setPricingCustomPriceFromVendor() {