fix: регистронезависимый поиск lot_name и удаление мёртвого кода

- SQLite-запросы по lot_name теперь используют UPPER(lot_name) IN/= для
  совместимости с легаси-данными, синхронизированными до нормализации регистра
- Удалена таблица local_components и весь связанный код синхронизации;
  источник данных для компонентов — local_pricelist_items
- Удалена функция getCategoryFromLotName из JS: категория берётся только
  из прайслиста, без инференса из имени лота
- Регистронезависимые сравнения lot_name в JS (warehouse stock set,
  addedLots, cartLots, allComponents.find, _bomLotValid)
- В support bundle добавлены: latest_pricelist_items.json, local.db,
  autocomplete_lots.json для диагностики

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 08:52:22 +03:00
parent 8b2dc6652a
commit 7d190cc7a8
19 changed files with 500 additions and 585 deletions

View File

@@ -656,16 +656,8 @@ func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string
return prices
}
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
lots := collectPricingLots(cfg, localCfg, true)
if s.localDB == nil || len(lots) == 0 {
return map[string]string{}
}
descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots)
if err != nil {
return map[string]string{}
}
return descriptions
func (s *ExportService) resolveLotDescriptions(_ *models.Configuration, _ *localdb.LocalConfiguration) map[string]string {
return map[string]string{}
}
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {

View File

@@ -111,6 +111,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
if len(req.Items) == 0 {
return nil, ErrEmptyQuote
}
for i := range req.Items {
req.Items[i].LotName = models.NormalizeLotName(req.Items[i].LotName)
}
// Strict local-first path: calculations use local SQLite snapshot regardless of online status.
if s.localDB != nil {
@@ -245,6 +248,16 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
if len(req.Items) == 0 {
return nil, ErrEmptyQuote
}
// Keep original lot names so the response mirrors what the caller sent.
// Normalization is applied only for internal DB lookups.
originalLotNames := make(map[string]string, len(req.Items))
for i := range req.Items {
upper := models.NormalizeLotName(req.Items[i].LotName)
if _, exists := originalLotNames[upper]; !exists {
originalLotNames[upper] = req.Items[i].LotName
}
req.Items[i].LotName = upper
}
lotNames := make([]string, 0, len(req.Items))
seenLots := make(map[string]struct{}, len(req.Items))
@@ -303,8 +316,12 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
}
for _, reqItem := range req.Items {
responseLotName := originalLotNames[reqItem.LotName]
if responseLotName == "" {
responseLotName = reqItem.LotName
}
item := PriceLevelsItem{
LotName: reqItem.LotName,
LotName: responseLotName,
Quantity: reqItem.Quantity,
PriceMissing: make([]string, 0, 3),
}

View File

@@ -404,6 +404,7 @@ func (s *Service) syncPricelists() (int, error) {
CreatedAt: pl.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
IsActive: true,
}
itemCount, err := s.syncNewPricelistSnapshot(localPL)
@@ -426,6 +427,12 @@ func (s *Service) syncPricelists() (int, error) {
slog.Info("deleted stale local pricelists", "deleted", removed)
}
// Mirror server-side deactivations: any local pricelist not in the current active set
// is marked is_active=false so offline lookups skip it.
if err := s.localDB.DeactivateLocalPricelistsNotIn(serverPricelistIDs); err != nil {
slog.Warn("failed to deactivate stale local pricelists", "error", err)
}
// Backfill lot_category for used pricelists (older local caches may miss the column values).
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
@@ -1638,24 +1645,3 @@ func (s *Service) getConnectionStatus() db.ConnectionStatus {
return s.connMgr.GetStatus()
}
// SyncComponentsIfEmpty syncs components from MariaDB when local_components is empty.
// Used by the background worker on first run to populate the catalog for new users.
func (s *Service) SyncComponentsIfEmpty() error {
if s.localDB.CountComponents() > 0 {
return nil
}
mariaDB, err := s.getDB()
if err != nil {
_ = s.localDB.SetComponentSyncResult("error", err.Error(), time.Now())
return err
}
result, err := s.localDB.SyncComponents(mariaDB)
now := time.Now()
if err != nil {
_ = s.localDB.SetComponentSyncResult("error", err.Error(), now)
return err
}
_ = s.localDB.SetComponentSyncResult("ok", "", now)
slog.Info("background sync: initial component sync completed", "synced", result.TotalSynced)
return nil
}

View File

@@ -80,11 +80,6 @@ func (w *Worker) runSync() {
return
}
// Populate component catalog on first run (empty local_components)
if err := w.service.SyncComponentsIfEmpty(); err != nil {
w.logger.Warn("background sync: initial component sync failed", "error", err)
}
// Push pending changes first
pushed, err := w.service.PushPendingChanges()
if err != nil {