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:
+69
-60
@@ -46,7 +46,6 @@ type LocalDB struct {
|
||||
var localReadOnlyCacheTables = []string{
|
||||
"local_pricelist_items",
|
||||
"local_pricelists",
|
||||
"local_components",
|
||||
"local_partnumber_book_items",
|
||||
"local_partnumber_books",
|
||||
}
|
||||
@@ -78,7 +77,6 @@ func ResetData(dbPath string) error {
|
||||
"local_configuration_versions",
|
||||
"local_pricelists",
|
||||
"local_pricelist_items",
|
||||
"local_components",
|
||||
"local_sync_guard_state",
|
||||
"pending_changes",
|
||||
"app_settings",
|
||||
@@ -224,7 +222,6 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
|
||||
&LocalConfigurationVersion{},
|
||||
&LocalPricelist{},
|
||||
&LocalPricelistItem{},
|
||||
&LocalComponent{},
|
||||
&AppSetting{},
|
||||
&LocalSyncGuardState{},
|
||||
&PendingChange{},
|
||||
@@ -1237,25 +1234,6 @@ func (l *LocalDB) GetLastComponentSyncError() string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func (l *LocalDB) SetComponentSyncResult(status, errorText string, attemptedAt time.Time) error {
|
||||
status = strings.TrimSpace(status)
|
||||
errorText = strings.TrimSpace(errorText)
|
||||
if status == "" {
|
||||
status = "unknown"
|
||||
}
|
||||
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := l.upsertAppSetting(tx, "last_component_sync_status", status, attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.upsertAppSetting(tx, "last_component_sync_error", errorText, attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := l.upsertAppSetting(tx, "last_component_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CountLocalPricelists returns the number of local pricelists
|
||||
func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
@@ -1271,11 +1249,10 @@ func (l *LocalDB) CountAllPricelistItems() int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
// CountComponents returns the number of rows in local_components.
|
||||
func (l *LocalDB) CountComponents() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalComponent{}).Count(&count)
|
||||
return count
|
||||
|
||||
// DBFilePath returns the path to the SQLite database file.
|
||||
func (l *LocalDB) DBFilePath() string {
|
||||
return l.path
|
||||
}
|
||||
|
||||
// DBFileSizeBytes returns the size of the SQLite database file in bytes.
|
||||
@@ -1287,11 +1264,11 @@ func (l *LocalDB) DBFileSizeBytes() int64 {
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||
// GetLatestLocalPricelist returns the most recently synced active estimate pricelist.
|
||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.
|
||||
Where("source = ?", "estimate").
|
||||
Where("source = ? AND is_active = ?", "estimate", true).
|
||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
@@ -1300,11 +1277,11 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
||||
// GetLatestLocalPricelistBySource returns the most recently synced active pricelist for a source.
|
||||
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.
|
||||
Where("source = ?", source).
|
||||
Where("source = ? AND is_active = ?", source, true).
|
||||
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&pricelist).Error; err != nil {
|
||||
@@ -1313,6 +1290,17 @@ func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelis
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// DeactivateLocalPricelistsNotIn marks all local pricelists with is_active=true whose
|
||||
// server_id is not in activeServerIDs as inactive. Used after each pricelist sync to
|
||||
// mirror server-side deactivations locally.
|
||||
func (l *LocalDB) DeactivateLocalPricelistsNotIn(activeServerIDs []uint) error {
|
||||
q := l.db.Model(&LocalPricelist{}).Where("is_active = ?", true)
|
||||
if len(activeServerIDs) > 0 {
|
||||
q = q.Where("server_id NOT IN ?", activeServerIDs)
|
||||
}
|
||||
return q.Update("is_active", false).Error
|
||||
}
|
||||
|
||||
// GetLocalPricelistByServerID returns a local pricelist by its server ID
|
||||
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
@@ -1380,6 +1368,30 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
// GetLocalPricelistCoverageByCategory returns item count per lot_category and the total
|
||||
// for the given local pricelist ID. Only items with price > 0 are counted.
|
||||
func (l *LocalDB) GetLocalPricelistCoverageByCategory(pricelistID uint) (map[string]int64, int64, error) {
|
||||
type row struct {
|
||||
Category string `gorm:"column:lot_category"`
|
||||
Count int64 `gorm:"column:cnt"`
|
||||
}
|
||||
var rows []row
|
||||
if err := l.db.Model(&LocalPricelistItem{}).
|
||||
Select("COALESCE(NULLIF(TRIM(lot_category),''), '?') AS lot_category, COUNT(*) AS cnt").
|
||||
Where("pricelist_id = ? AND price > 0", pricelistID).
|
||||
Group("lot_category").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
result := make(map[string]int64, len(rows))
|
||||
var total int64
|
||||
for _, r := range rows {
|
||||
result[r.Category] = r.Count
|
||||
total += r.Count
|
||||
}
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
|
||||
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
|
||||
var count int64
|
||||
@@ -1444,10 +1456,11 @@ func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||
// GetLocalPriceForLot returns the price for a lot from a local pricelist.
|
||||
// Matching is case-insensitive via UPPER(lot_name) to handle legacy mixed-case rows.
|
||||
func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||
var item LocalPricelistItem
|
||||
if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).
|
||||
if err := l.db.Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
|
||||
First(&item).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -1455,26 +1468,32 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
|
||||
}
|
||||
|
||||
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
|
||||
// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result.
|
||||
// Missing lots are omitted from the result.
|
||||
// lotNames must already be normalized (uppercased); matching is done via UPPER(lot_name) to handle
|
||||
// legacy rows that were stored in mixed case before normalization was enforced at sync time.
|
||||
// Keys in the returned map are uppercased (matching the input lotNames).
|
||||
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
||||
result := make(map[string]float64, len(lotNames))
|
||||
if len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
var rows []row
|
||||
// Use UPPER(lot_name) so rows synced before normalization (mixed-case) are still matched.
|
||||
if err := l.db.Model(&LocalPricelistItem{}).
|
||||
Select("lot_name, price").
|
||||
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
|
||||
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, lotNames).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Price > 0 {
|
||||
result[r.LotName] = r.Price
|
||||
// Key must be uppercase to match callers that normalise lot names before lookup.
|
||||
result[strings.ToUpper(r.LotName)] = r.Price
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
@@ -1497,15 +1516,27 @@ func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uin
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
LotCategory string `gorm:"column:lot_category"`
|
||||
}
|
||||
// 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 []row
|
||||
if err := l.db.Model(&LocalPricelistItem{}).
|
||||
Select("lot_name, lot_category").
|
||||
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
|
||||
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", localPL.ID, upper).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
result[r.LotName] = r.LotCategory
|
||||
orig := upperToOrig[strings.ToUpper(r.LotName)]
|
||||
if orig == "" {
|
||||
orig = r.LotName
|
||||
}
|
||||
result[orig] = r.LotCategory
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1871,28 +1902,6 @@ func (l *LocalDB) GetLocalPricelistItemsPage(pricelistID uint, search string, pa
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetLocalComponentDescriptionsByLotNames returns a map of lot_name → lot_description for the given lots.
|
||||
func (l *LocalDB) GetLocalComponentDescriptionsByLotNames(lotNames []string) (map[string]string, error) {
|
||||
if len(lotNames) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
type row struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
}
|
||||
var rows []row
|
||||
if err := l.db.Table("local_components").
|
||||
Select("lot_name, lot_description").
|
||||
Where("lot_name IN ?", lotNames).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("fetch component descriptions: %w", err)
|
||||
}
|
||||
m := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
m[r.LotName] = r.LotDescription
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// GetSchemaMigrations returns all applied local schema migrations ordered by applied_at.
|
||||
func (l *LocalDB) GetSchemaMigrations() ([]LocalSchemaMigration, error) {
|
||||
|
||||
Reference in New Issue
Block a user