- 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>
234 lines
6.5 KiB
Go
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
|
|
}
|
|
|