Files
QuoteForge/internal/localdb/components.go
Michael Chus 7d190cc7a8 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>
2026-06-26 08:52:22 +03:00

234 lines
6.5 KiB
Go

package localdb
import (
"fmt"
"strings"
"time"
)
// ComponentFilter for searching with filters
type ComponentFilter struct {
Category string
Search string
HasPrice bool
}
// ComponentSyncResult contains statistics from component sync
type ComponentSyncResult struct {
TotalSynced int
NewCount int
UpdateCount int
Duration time.Duration
}
// latestActivePricelistID returns the local DB id of the most recently created
// active pricelist for the given source ("estimate", "warehouse", etc.).
func (l *LocalDB) latestActivePricelistID(source string) (uint, error) {
var id uint
err := l.db.Table("local_pricelists").
Select("id").
Where("is_active = ? AND source = ?", true, source).
Order("created_at DESC, id DESC").
Limit(1).
Scan(&id).Error
if err != nil {
return 0, err
}
if id == 0 {
return 0, fmt.Errorf("no active %s pricelist", source)
}
return id, nil
}
// pricelistItemRow is used for scanning rows from local_pricelist_items.
type pricelistItemRow struct {
LotName string `gorm:"column:lot_name"`
Category string `gorm:"column:lot_category"`
}
func (r pricelistItemRow) toLocalComponent() LocalComponent {
return LocalComponent{
LotName: r.LotName,
Category: r.Category,
}
}
// SearchLocalComponents searches components in the latest active estimate
// pricelist by lot_name.
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
if limit <= 0 {
limit = 50
}
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return nil, err
}
db := l.db.Table("local_pricelist_items").
Where("pricelist_id = ?", pricelistID)
if query != "" {
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
}
var rows []pricelistItemRow
if err := db.Select("lot_name, lot_category").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
return nil, err
}
components := make([]LocalComponent, len(rows))
for i, r := range rows {
components[i] = r.toLocalComponent()
}
return components, nil
}
// SearchLocalComponentsByCategory searches components in the latest active
// estimate pricelist filtered by category.
func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) {
if limit <= 0 {
limit = 50
}
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return nil, err
}
db := l.db.Table("local_pricelist_items").
Where("pricelist_id = ? AND UPPER(lot_category) = ?", pricelistID, strings.ToUpper(category))
if query != "" {
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
}
var rows []pricelistItemRow
if err := db.Select("lot_name, lot_category").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
return nil, err
}
components := make([]LocalComponent, len(rows))
for i, r := range rows {
components[i] = r.toLocalComponent()
}
return components, nil
}
// ListComponents returns components from the latest active estimate pricelist
// with optional category/search filtering and pagination.
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return nil, 0, err
}
db := l.db.Table("local_pricelist_items").
Where("pricelist_id = ?", pricelistID)
if filter.Category != "" {
db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(filter.Category))
}
if filter.Search != "" {
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(filter.Search)+"%")
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []pricelistItemRow
if err := db.Select("lot_name, lot_category").Order("lot_name").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil {
return nil, 0, err
}
components := make([]LocalComponent, len(rows))
for i, r := range rows {
components[i] = r.toLocalComponent()
}
return components, total, nil
}
// GetLocalComponent returns a single component by lot_name from the latest
// active estimate pricelist.
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return nil, err
}
var row pricelistItemRow
if err := l.db.Table("local_pricelist_items").
Select("lot_name, lot_category").
Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
First(&row).Error; err != nil {
return nil, err
}
c := row.toLocalComponent()
return &c, nil
}
// GetLocalComponentCategoriesByLotNames returns category for each lot_name
// from the latest active estimate pricelist.
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
result := make(map[string]string, len(lotNames))
if len(lotNames) == 0 {
return result, nil
}
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return result, nil
}
// Build uppercase → original mapping so result keys match what the caller passed.
upperToOrig := make(map[string]string, len(lotNames))
upper := make([]string, len(lotNames))
for i, n := range lotNames {
u := strings.ToUpper(n)
upper[i] = u
upperToOrig[u] = n
}
var rows []pricelistItemRow
if err := l.db.Table("local_pricelist_items").
Select("lot_name, lot_category").
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, upper).
Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
orig := upperToOrig[strings.ToUpper(r.LotName)]
if orig == "" {
orig = r.LotName
}
result[orig] = r.Category
}
return result, nil
}
// GetLocalComponentCategories returns distinct categories from the latest
// active estimate pricelist.
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return nil, err
}
var categories []string
if err := l.db.Table("local_pricelist_items").
Where("pricelist_id = ? AND lot_category != ''", pricelistID).
Distinct("lot_category").
Order("lot_category").
Pluck("lot_category", &categories).Error; err != nil {
return nil, err
}
return categories, nil
}
// CountComponents returns the number of distinct lot names in the latest
// active estimate pricelist (used to check if data is available).
func (l *LocalDB) CountComponents() int64 {
pricelistID, err := l.latestActivePricelistID("estimate")
if err != nil {
return 0
}
var count int64
l.db.Table("local_pricelist_items").Where("pricelist_id = ?", pricelistID).Count(&count)
return count
}