Fix storage sync and configurator category visibility

This commit is contained in:
Mikhail Chusavitin
2026-04-15 18:40:34 +03:00
parent aea6bf91ab
commit 7e1e2ac18d
5 changed files with 63 additions and 232 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

View File

@@ -71,18 +71,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 +97,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

@@ -789,9 +789,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 +802,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

@@ -1155,17 +1155,34 @@ 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'];
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 pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
TAB_CONFIG.base.categories = TAB_CONFIG.base.categories.filter( const pciSections = [
c => !STORAGE_ONLY_BASE_CATEGORIES.includes(c) { title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
); { title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
// Remove HIC from PCI tab { title: 'HBA', categories: ['HBA'] },
TAB_CONFIG.pci.categories = TAB_CONFIG.pci.categories.filter(c => c !== 'HIC'); { title: 'HIC', categories: ['HIC'] }
TAB_CONFIG.pci.sections = TAB_CONFIG.pci.sections.filter(s => s.title !== 'HIC'); ];
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.pci.categories = pciCategories.filter(c => {
return configType === 'storage' ? true : c !== 'HIC';
});
TAB_CONFIG.pci.sections = pciSections.filter(section => {
return configType === 'storage' ? true : section.title !== 'HIC';
});
// 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)