Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b5e04168a | ||
|
|
61d23ef8c4 | ||
| 11fd314a65 | |||
| e59a43c279 | |||
|
|
83a3202bdf | ||
|
|
4bc7979a70 | ||
|
|
1137c6d4db | ||
|
|
7e1e2ac18d |
@@ -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.
@@ -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++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
118
internal/services/sync/service_pricelist_upsert_test.go
Normal file
118
internal/services/sync/service_pricelist_upsert_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
13
releases/v1.7/RELEASE_NOTES.md
Normal file
13
releases/v1.7/RELEASE_NOTES.md
Normal 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 вместо удаления строки;
|
||||||
|
- кнопка «Пересчитать эстимейт» переименована в «Перенести в эстимейт».
|
||||||
13
releases/v1.8/RELEASE_NOTES.md
Normal file
13
releases/v1.8/RELEASE_NOTES.md
Normal 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 ./...` проходит полностью.
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user