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:
@@ -177,22 +177,12 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
lotNames := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
lotNames[i] = item.LotName
|
||||
}
|
||||
descMap, err := h.localDB.GetLocalComponentDescriptionsByLotNames(lotNames)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
|
||||
resultItems := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
resultItems = append(resultItems, gin.H{
|
||||
"id": item.ID,
|
||||
"lot_name": item.LotName,
|
||||
"lot_description": descMap[item.LotName],
|
||||
"lot_description": "",
|
||||
"price": item.Price,
|
||||
"category": item.LotCategory,
|
||||
"available_qty": item.AvailableQty,
|
||||
|
||||
@@ -74,7 +74,7 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
||||
|
||||
// local_db_stats.json
|
||||
writeJSON("local_db_stats.json", map[string]any{
|
||||
"components": h.localDB.CountLocalComponents(),
|
||||
"components": h.localDB.CountComponents(),
|
||||
"configurations": h.localDB.CountConfigurations(),
|
||||
"projects": h.localDB.CountProjects(),
|
||||
"pricelists": h.localDB.CountLocalPricelists(),
|
||||
@@ -139,6 +139,7 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
IsUsed bool `json:"is_used"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
bySource := map[string][]plEntry{}
|
||||
for _, pl := range pricelists {
|
||||
@@ -150,12 +151,78 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
||||
CreatedAt: pl.CreatedAt,
|
||||
SyncedAt: pl.SyncedAt,
|
||||
IsUsed: pl.IsUsed,
|
||||
IsActive: pl.IsActive,
|
||||
}
|
||||
bySource[pl.Source] = append(bySource[pl.Source], e)
|
||||
}
|
||||
writeJSON("pricelists.json", bySource)
|
||||
}
|
||||
|
||||
// pricelist_coverage.json — for each local estimate pricelist: item count by lot_category
|
||||
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
|
||||
type catRow struct {
|
||||
Category string `json:"category"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
type plCoverage struct {
|
||||
Version string `json:"version"`
|
||||
ServerID uint `json:"server_id"`
|
||||
TotalItems int64 `json:"total_items"`
|
||||
Categories []catRow `json:"categories"`
|
||||
}
|
||||
rows, total, catErr := h.localDB.GetLocalPricelistCoverageByCategory(pl.ID)
|
||||
if catErr == nil {
|
||||
cats := make([]catRow, 0, len(rows))
|
||||
for cat, cnt := range rows {
|
||||
cats = append(cats, catRow{Category: cat, Count: cnt})
|
||||
}
|
||||
writeJSON("pricelist_coverage.json", plCoverage{
|
||||
Version: pl.Version,
|
||||
ServerID: pl.ServerID,
|
||||
TotalItems: total,
|
||||
Categories: cats,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// configurator_settings.json — what /api/configurator-settings actually returns
|
||||
if cfgSettings, err := h.localDB.GetConfiguratorSettings(); err == nil {
|
||||
writeJSON("configurator_settings.json", cfgSettings)
|
||||
} else {
|
||||
writeJSON("configurator_settings.json", map[string]any{"error": err.Error()})
|
||||
}
|
||||
|
||||
// component_categories.json — distinct categories in active estimate pricelist
|
||||
if cats, err := h.localDB.GetLocalComponentCategories(); err == nil {
|
||||
writeJSON("component_categories.json", cats)
|
||||
}
|
||||
|
||||
// autocomplete_lots.json — per-category breakdown of lots with their prices
|
||||
// Mirrors what filterAutocomplete() works with: lot_name + estimate_price per category.
|
||||
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
|
||||
if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil {
|
||||
type lotEntry struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Price float64 `json:"price"`
|
||||
HasPrice bool `json:"has_price"`
|
||||
}
|
||||
byCategory := map[string][]lotEntry{}
|
||||
for _, it := range items {
|
||||
entry := lotEntry{
|
||||
LotName: it.LotName,
|
||||
Price: it.Price,
|
||||
HasPrice: it.Price > 0,
|
||||
}
|
||||
byCategory[it.LotCategory] = append(byCategory[it.LotCategory], entry)
|
||||
}
|
||||
writeJSON("autocomplete_lots.json", map[string]any{
|
||||
"pricelist_version": pl.Version,
|
||||
"pricelist_id": pl.ServerID,
|
||||
"by_category": byCategory,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// schema_migrations.json
|
||||
migrations, err := h.localDB.GetSchemaMigrations()
|
||||
if err != nil {
|
||||
@@ -163,6 +230,44 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) {
|
||||
}
|
||||
writeJSON("schema_migrations.json", migrations)
|
||||
|
||||
// latest_pricelist_items.json — all items from the most recent active estimate pricelist
|
||||
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
|
||||
if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil {
|
||||
type plItem struct {
|
||||
LotName string `json:"lot_name"`
|
||||
LotCategory string `json:"lot_category"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
out := make([]plItem, len(items))
|
||||
for i, it := range items {
|
||||
out[i] = plItem{
|
||||
LotName: it.LotName,
|
||||
LotCategory: it.LotCategory,
|
||||
Price: it.Price,
|
||||
}
|
||||
}
|
||||
writeJSON("latest_pricelist_items.json", map[string]any{
|
||||
"pricelist_version": pl.Version,
|
||||
"pricelist_id": pl.ServerID,
|
||||
"source": pl.Source,
|
||||
"item_count": len(out),
|
||||
"items": out,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// local.db — full SQLite database file (for deep diagnostics)
|
||||
if dbPath := h.localDB.DBFilePath(); dbPath != "" {
|
||||
if f, err := os.Open(dbPath); err == nil {
|
||||
defer f.Close()
|
||||
if w, err := zw.Create("local.db"); err == nil {
|
||||
if _, err := io.Copy(w, f); err != nil {
|
||||
slog.Warn("support bundle: error copying local.db", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// app.log (tail 5 MiB)
|
||||
if h.logFilePath != "" {
|
||||
if f, err := os.Open(h.logFilePath); err == nil {
|
||||
|
||||
@@ -50,7 +50,6 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
||||
|
||||
// SyncStatusResponse represents the sync status
|
||||
type SyncStatusResponse struct {
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||
@@ -61,7 +60,6 @@ type SyncStatusResponse struct {
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
||||
}
|
||||
@@ -80,19 +78,16 @@ type SyncReadinessResponse struct {
|
||||
func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
connStatus := h.connMgr.GetStatus()
|
||||
isOnline := connStatus.IsConnected && strings.TrimSpace(connStatus.LastError) == ""
|
||||
lastComponentSync := h.localDB.GetComponentSyncTime()
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
componentsCount := h.localDB.CountLocalComponents()
|
||||
componentsCount := h.localDB.CountComponents()
|
||||
pricelistsCount := h.localDB.CountLocalPricelists()
|
||||
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||
hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed")
|
||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||
readiness := h.getReadinessLocal()
|
||||
|
||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||
LastComponentSync: lastComponentSync,
|
||||
LastPricelistSync: lastPricelistSync,
|
||||
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||
@@ -103,7 +98,6 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
ComponentsCount: componentsCount,
|
||||
PricelistsCount: pricelistsCount,
|
||||
ServerPricelists: 0,
|
||||
NeedComponentSync: needComponentSync,
|
||||
NeedPricelistSync: lastPricelistSync == nil || hasFailedSync,
|
||||
Readiness: readiness,
|
||||
})
|
||||
@@ -169,52 +163,6 @@ type SyncResultResponse struct {
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// SyncComponents syncs components from MariaDB to local SQLite
|
||||
// POST /api/sync/components
|
||||
func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||
if !h.ensureSyncReadiness(c) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get database connection from ConnectionManager
|
||||
mariaDB, err := h.connMgr.GetDB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "database connection failed",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
result, err := h.localDB.SyncComponents(mariaDB)
|
||||
if err != nil {
|
||||
_ = h.localDB.SetComponentSyncResult("error", err.Error(), now)
|
||||
h.localDB.AppendSyncLog("components", "error", err.Error(), 0, now, time.Since(now).Milliseconds())
|
||||
slog.Error("component sync failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "component sync failed",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
_ = h.localDB.SetComponentSyncResult("ok", "", now)
|
||||
h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds())
|
||||
|
||||
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
|
||||
slog.Warn("qt_settings sync failed", "error", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
Message: "Components synced successfully",
|
||||
Synced: result.TotalSynced,
|
||||
Duration: result.Duration.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// SyncPricelists syncs pricelists from MariaDB to local SQLite
|
||||
// POST /api/sync/pricelists
|
||||
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
@@ -280,7 +228,6 @@ type SyncAllResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
PendingPushed int `json:"pending_pushed"`
|
||||
ComponentsSynced int `json:"components_synced"`
|
||||
PricelistsSynced int `json:"pricelists_synced"`
|
||||
ProjectsImported int `json:"projects_imported"`
|
||||
ProjectsUpdated int `json:"projects_updated"`
|
||||
@@ -301,7 +248,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
var pendingPushed, componentsSynced, pricelistsSynced int
|
||||
var pricelistsSynced int
|
||||
|
||||
// Push local pending changes first (projects/configurations)
|
||||
pendingPushed, err := h.syncService.PushPendingChanges()
|
||||
@@ -315,38 +262,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sync components
|
||||
mariaDB, err := h.connMgr.GetDB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "database connection failed",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
compNow := time.Now()
|
||||
compResult, err := h.localDB.SyncComponents(mariaDB)
|
||||
if err != nil {
|
||||
_ = h.localDB.SetComponentSyncResult("error", err.Error(), compNow)
|
||||
h.localDB.AppendSyncLog("components", "error", err.Error(), 0, compNow, time.Since(compNow).Milliseconds())
|
||||
slog.Error("component sync failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "component sync failed",
|
||||
})
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
_ = h.localDB.SetComponentSyncResult("ok", "", compNow)
|
||||
h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds())
|
||||
componentsSynced = compResult.TotalSynced
|
||||
|
||||
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
|
||||
slog.Warn("qt_settings sync failed", "error", err)
|
||||
}
|
||||
|
||||
// Sync pricelists
|
||||
plNow := time.Now()
|
||||
pricelistsSynced, err = h.syncService.SyncPricelists()
|
||||
@@ -354,10 +269,9 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plNow, time.Since(plNow).Milliseconds())
|
||||
slog.Error("pricelist sync failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "pricelist sync failed",
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"success": false,
|
||||
"error": "pricelist sync failed",
|
||||
"pending_pushed": pendingPushed,
|
||||
})
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -375,7 +289,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
"success": false,
|
||||
"error": "project import failed",
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"pricelists_synced": pricelistsSynced,
|
||||
})
|
||||
_ = c.Error(err)
|
||||
@@ -389,7 +302,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
"success": false,
|
||||
"error": "configuration import failed",
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"pricelists_synced": pricelistsSynced,
|
||||
"projects_imported": projectsResult.Imported,
|
||||
"projects_updated": projectsResult.Updated,
|
||||
@@ -403,7 +315,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
Success: true,
|
||||
Message: "Full sync completed successfully",
|
||||
PendingPushed: pendingPushed,
|
||||
ComponentsSynced: componentsSynced,
|
||||
PricelistsSynced: pricelistsSynced,
|
||||
ProjectsImported: projectsResult.Imported,
|
||||
ProjectsUpdated: projectsResult.Updated,
|
||||
@@ -564,7 +475,7 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
// Get local counts
|
||||
configCount := h.localDB.CountConfigurations()
|
||||
projectCount := h.localDB.CountProjects()
|
||||
componentCount := h.localDB.CountLocalComponents()
|
||||
componentCount := h.localDB.CountComponents()
|
||||
pricelistCount := h.localDB.CountLocalPricelists()
|
||||
|
||||
// Get error count (only changes with LastError != "")
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
@@ -172,7 +172,7 @@ func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpe
|
||||
merged := make(map[string]int, len(in))
|
||||
order := make([]string, 0, len(in))
|
||||
for _, m := range in {
|
||||
lot := strings.TrimSpace(m.LotName)
|
||||
lot := models.NormalizeLotName(m.LotName)
|
||||
if lot == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user