From df14da226556e9e6310c17bcf46956fcc154cfcb Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 17 Mar 2026 12:37:58 +0300 Subject: [PATCH] Improve pricing modal performance and charting --- internal/handlers/pricing_components.go | 266 ++++++++++++++++-------- internal/repository/component.go | 77 +++++-- internal/repository/pricelist.go | 54 ++++- web/static/js/admin_pricing.js | 148 ++++++++++++- web/templates/admin_pricing.html | 17 ++ 5 files changed, 451 insertions(+), 111 deletions(-) diff --git a/internal/handlers/pricing_components.go b/internal/handlers/pricing_components.go index 63e542b..b117ca1 100644 --- a/internal/handlers/pricing_components.go +++ b/internal/handlers/pricing_components.go @@ -22,6 +22,101 @@ type ComponentWithCount struct { UsedInMeta []string `json:"used_in_meta,omitempty"` // List of meta-articles that use this component } +func (h *PricingHandler) expandComponentSourceLots(components []models.LotMetadata) map[string][]string { + result := make(map[string][]string, len(components)) + wildcardPrefixes := make(map[string]struct{}) + + for _, comp := range components { + base := []string{comp.LotName} + if strings.TrimSpace(comp.MetaPrices) == "" { + result[comp.LotName] = base + continue + } + + seen := map[string]struct{}{comp.LotName: {}} + sources := strings.Split(comp.MetaPrices, ",") + for _, source := range sources { + source = strings.TrimSpace(source) + if source == "" { + continue + } + if strings.HasSuffix(source, "*") { + prefix := strings.TrimSuffix(source, "*") + if prefix != "" { + wildcardPrefixes[prefix] = struct{}{} + } + continue + } + if _, ok := seen[source]; ok { + continue + } + base = append(base, source) + seen[source] = struct{}{} + } + result[comp.LotName] = base + } + + if len(wildcardPrefixes) == 0 { + return result + } + + prefixes := make([]string, 0, len(wildcardPrefixes)) + for prefix := range wildcardPrefixes { + prefixes = append(prefixes, prefix) + } + + query := h.db.Model(&models.LotMetadata{}) + for i, prefix := range prefixes { + clause := "lot_name LIKE ?" + arg := prefix + "%" + if i == 0 { + query = query.Where(clause, arg) + } else { + query = query.Or(clause, arg) + } + } + + var matchingLots []string + _ = query.Pluck("lot_name", &matchingLots).Error + + matchesByPrefix := make(map[string][]string, len(prefixes)) + for _, lotName := range matchingLots { + for _, prefix := range prefixes { + if strings.HasPrefix(lotName, prefix) { + matchesByPrefix[prefix] = append(matchesByPrefix[prefix], lotName) + } + } + } + + for _, comp := range components { + if strings.TrimSpace(comp.MetaPrices) == "" { + continue + } + existing := result[comp.LotName] + seen := make(map[string]struct{}, len(existing)) + for _, lot := range existing { + seen[lot] = struct{}{} + } + for _, source := range strings.Split(comp.MetaPrices, ",") { + source = strings.TrimSpace(source) + if !strings.HasSuffix(source, "*") { + continue + } + prefix := strings.TrimSuffix(source, "*") + for _, lot := range matchesByPrefix[prefix] { + if _, ok := seen[lot]; ok { + continue + } + existing = append(existing, lot) + seen[lot] = struct{}{} + } + } + result[comp.LotName] = existing + } + + return result +} + func (h *PricingHandler) ListComponents(c *gin.Context) { // Check if we're in offline mode if h.componentRepo == nil { @@ -60,23 +155,13 @@ func (h *PricingHandler) ListComponents(c *gin.Context) { return } - // Get all lot names for meta expansion - var allLotNames []string - h.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames) - // Build per-component source lots for quote counts - componentSources := make(map[string][]string, len(components)) + componentSources := h.expandComponentSourceLots(components) uniqueLots := make(map[string]struct{}) lotNames := make([]string, len(components)) for i, comp := range components { lotNames[i] = comp.LotName - var sourceLots []string - if strings.TrimSpace(comp.MetaPrices) != "" { - sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames) - } else { - sourceLots = []string{comp.LotName} - } - componentSources[comp.LotName] = sourceLots + sourceLots := componentSources[comp.LotName] for _, ln := range sourceLots { if ln == "" { continue @@ -561,91 +646,110 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) { lotNames = h.expandMetaPrices(req.MetaPrices, req.LotName) } - // Get all prices for calculations (from all relevant lots) - var allPrices []float64 - for _, lotName := range lotNames { - var lotPrices []float64 - if strings.HasSuffix(lotName, "*") { - // Wildcard pattern - pattern := strings.TrimSuffix(lotName, "*") + "%" - h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices) - } else { - h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &lotPrices) - } - allPrices = append(allPrices, lotPrices...) + type quoteRow struct { + Lot string + Date time.Time + Price float64 + LotLogID uint + } + var quoteRows []quoteRow + if len(lotNames) > 0 { + h.db.Raw(` + SELECT lot, date, price, lot_log_id + FROM lot_log + WHERE lot IN ? + ORDER BY date ASC, lot_log_id ASC + `, lotNames).Scan("eRows) } - // Calculate median for all time + var allPrices []float64 + var periodPrices []float64 + var quoteCountTotal int64 + var quoteCountPeriod int64 var medianAllTime *float64 - if len(allPrices) > 0 { + estimateHistory := make([]LotHistoryPoint, 0) + + var lastPrice struct { + Price *float64 + Date *time.Time + } + var lastPriceLogID uint + + cutoffDate := time.Time{} + if req.PeriodDays > 0 { + now := time.Now() + cutoffDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -req.PeriodDays) + } + + if len(quoteRows) > 0 { + allPrices = make([]float64, 0, len(quoteRows)) + if req.PeriodDays > 0 { + periodPrices = make([]float64, 0, len(quoteRows)) + } + + var currentDay string + var currentDaySum float64 + var currentDayCount int + + flushHistory := func() { + if currentDay == "" || currentDayCount == 0 { + return + } + estimateHistory = append(estimateHistory, LotHistoryPoint{ + Date: currentDay, + Price: currentDaySum / float64(currentDayCount), + }) + } + + for _, row := range quoteRows { + quoteCountTotal++ + allPrices = append(allPrices, row.Price) + + dayKey := row.Date.Format("2006-01-02") + if currentDay != dayKey { + flushHistory() + currentDay = dayKey + currentDaySum = 0 + currentDayCount = 0 + } + currentDaySum += row.Price + currentDayCount++ + + if req.PeriodDays <= 0 || !row.Date.Before(cutoffDate) { + quoteCountPeriod++ + periodPrices = append(periodPrices, row.Price) + } + + if row.Lot == req.LotName { + if lastPrice.Date == nil || row.Date.After(*lastPrice.Date) || (row.Date.Equal(*lastPrice.Date) && row.LotLogID > lastPriceLogID) { + price := row.Price + date := row.Date + lastPrice.Price = &price + lastPrice.Date = &date + lastPriceLogID = row.LotLogID + } + } + } + flushHistory() + sortFloat64s(allPrices) median := calculateMedian(allPrices) medianAllTime = &median } - // Get quote count (from all relevant lots) - total count - var quoteCountTotal int64 - for _, lotName := range lotNames { - var count int64 - if strings.HasSuffix(lotName, "*") { - pattern := strings.TrimSuffix(lotName, "*") + "%" - h.db.Model(&models.LotLog{}).Where("lot LIKE ?", pattern).Count(&count) - } else { - h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count) - } - quoteCountTotal += count - } - - // Get quote count for specified period (if period is > 0) - var quoteCountPeriod int64 - if req.PeriodDays > 0 { - for _, lotName := range lotNames { - var count int64 - if strings.HasSuffix(lotName, "*") { - pattern := strings.TrimSuffix(lotName, "*") + "%" - h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, pattern, req.PeriodDays).Scan(&count) - } else { - h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, lotName, req.PeriodDays).Scan(&count) - } - quoteCountPeriod += count - } - } else { - // If no period specified, period count equals total count + if req.PeriodDays <= 0 { + periodPrices = allPrices quoteCountPeriod = quoteCountTotal } - // Get last received price (from the main lot only) - var lastPrice struct { - Price *float64 - Date *time.Time - } - h.db.Raw(`SELECT price, date FROM lot_log WHERE lot = ? ORDER BY date DESC, lot_log_id DESC LIMIT 1`, req.LotName).Scan(&lastPrice) - // Calculate new price based on parameters (method, period, coefficient) method := req.Method if method == "" { method = "median" } - var prices []float64 - if req.PeriodDays > 0 { - for _, lotName := range lotNames { - var lotPrices []float64 - if strings.HasSuffix(lotName, "*") { - pattern := strings.TrimSuffix(lotName, "*") + "%" - h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, - pattern, req.PeriodDays).Pluck("price", &lotPrices) - } else { - h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, - lotName, req.PeriodDays).Pluck("price", &lotPrices) - } - prices = append(prices, lotPrices...) - } - // Fall back to all time if no prices in period - if len(prices) == 0 { - prices = allPrices - } - } else { + prices := periodPrices + if len(prices) == 0 { prices = allPrices } @@ -668,6 +772,7 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "lot_name": req.LotName, "current_price": comp.CurrentPrice, + "estimate_history": estimateHistory, "median_all_time": medianAllTime, "new_price": newPrice, "quote_count_total": quoteCountTotal, @@ -677,4 +782,3 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) { "last_price_date": lastPrice.Date, }) } - diff --git a/internal/repository/component.go b/internal/repository/component.go index 2052ed3..6296c6f 100644 --- a/internal/repository/component.go +++ b/internal/repository/component.go @@ -25,29 +25,29 @@ type ComponentFilter struct { } func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { - var components []models.LotMetadata var total int64 - query := r.db.Model(&models.LotMetadata{}). - Preload("Lot"). - Preload("Category") + baseQuery := r.db.Table("qt_lot_metadata AS m"). + Joins("LEFT JOIN lot AS l ON l.lot_name = m.lot_name"). + Joins("LEFT JOIN qt_categories AS c ON c.id = m.category_id") if filter.Category != "" { - query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id"). - Where("qt_categories.code = ?", filter.Category) + baseQuery = baseQuery.Where("c.code = ?", filter.Category) } if filter.Search != "" { search := "%" + filter.Search + "%" - query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search) + baseQuery = baseQuery.Where("m.lot_name LIKE ? OR m.model LIKE ?", search, search) } if filter.HasPrice { - query = query.Where("current_price IS NOT NULL AND current_price > 0") + baseQuery = baseQuery.Where("m.current_price IS NOT NULL AND m.current_price > 0") } if filter.ExcludeHidden { - query = query.Where("is_hidden = ? OR is_hidden IS NULL", false) + baseQuery = baseQuery.Where("m.is_hidden = ? OR m.is_hidden IS NULL", false) } - query.Count(&total) + if err := baseQuery.Count(&total).Error; err != nil { + return nil, 0, err + } // Apply sorting sortDir := "ASC" @@ -55,30 +55,65 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([ sortDir = "DESC" } + query := baseQuery.Session(&gorm.Session{}) switch filter.SortField { case "popularity_score": - query = query.Order("popularity_score " + sortDir) + query = query.Order("m.popularity_score " + sortDir) case "current_price": - query = query.Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END"). - Order("current_price " + sortDir) + query = query.Order("CASE WHEN m.current_price IS NULL OR m.current_price = 0 THEN 1 ELSE 0 END"). + Order("m.current_price " + sortDir) case "lot_name": - query = query.Order("lot_name " + sortDir) + query = query.Order("m.lot_name " + sortDir) case "quote_count": - // Sort by quote count from lot_log table query = query. - Select("qt_lot_metadata.*, (SELECT COUNT(*) FROM lot_log WHERE lot_log.lot = qt_lot_metadata.lot_name) as quote_count_sort"). + Select("m.*, l.lot_description AS lot_description, c.id AS category_join_id, c.code AS category_code, (SELECT COUNT(*) FROM lot_log WHERE lot_log.lot = m.lot_name) as quote_count_sort"). Order("quote_count_sort " + sortDir) default: - // Default: sort by popularity, no price goes last query = query. - Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END"). - Order("popularity_score DESC") + Order("CASE WHEN m.current_price IS NULL OR m.current_price = 0 THEN 1 ELSE 0 END"). + Order("m.popularity_score DESC") } - err := query. + type componentRow struct { + models.LotMetadata + LotDescription string `gorm:"column:lot_description"` + CategoryJoinID *uint `gorm:"column:category_join_id"` + CategoryCode *string `gorm:"column:category_code"` + } + var rows []componentRow + selectQuery := query + if filter.SortField != "quote_count" { + selectQuery = selectQuery.Select("m.*, l.lot_description AS lot_description, c.id AS category_join_id, c.code AS category_code") + } + err := selectQuery. Offset(offset). Limit(limit). - Find(&components).Error + Scan(&rows).Error + if err != nil { + return nil, total, err + } + + components := make([]models.LotMetadata, len(rows)) + for i, row := range rows { + comp := row.LotMetadata + comp.Lot = &models.Lot{ + LotName: comp.LotName, + LotDescription: row.LotDescription, + } + if row.CategoryCode != nil || row.CategoryJoinID != nil { + comp.Category = &models.Category{ + ID: 0, + Code: "", + } + if row.CategoryJoinID != nil { + comp.Category.ID = *row.CategoryJoinID + } + if row.CategoryCode != nil { + comp.Category.Code = *row.CategoryCode + } + } + components[i] = comp + } return components, total, err } diff --git a/internal/repository/pricelist.go b/internal/repository/pricelist.go index 3169c68..d47acd6 100644 --- a/internal/repository/pricelist.go +++ b/internal/repository/pricelist.go @@ -86,13 +86,53 @@ func (r *PricelistRepository) CountActive() (int64, error) { } func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []models.PricelistSummary { - // Get item counts for each pricelist + if len(pricelists) == 0 { + return []models.PricelistSummary{} + } + + ids := make([]uint, 0, len(pricelists)) + for _, pl := range pricelists { + ids = append(ids, pl.ID) + } + + itemCounts := make(map[uint]int64, len(pricelists)) + type itemCountRow struct { + PricelistID uint `gorm:"column:pricelist_id"` + Count int64 `gorm:"column:cnt"` + } + var itemRows []itemCountRow + _ = r.db.Model(&models.PricelistItem{}). + Select("pricelist_id, COUNT(*) as cnt"). + Where("pricelist_id IN ?", ids). + Group("pricelist_id"). + Scan(&itemRows).Error + for _, row := range itemRows { + itemCounts[row.PricelistID] = row.Count + } + + usageCounts := make(map[uint]int64, len(pricelists)) + type usageRow struct { + PricelistID uint `gorm:"column:pricelist_id"` + Count int64 `gorm:"column:cnt"` + } + var usageRows []usageRow + usageQueries := []string{ + "SELECT pricelist_id, COUNT(*) as cnt FROM qt_configurations WHERE pricelist_id IN ? GROUP BY pricelist_id", + "SELECT warehouse_pricelist_id as pricelist_id, COUNT(*) as cnt FROM qt_configurations WHERE warehouse_pricelist_id IN ? GROUP BY warehouse_pricelist_id", + "SELECT competitor_pricelist_id as pricelist_id, COUNT(*) as cnt FROM qt_configurations WHERE competitor_pricelist_id IN ? GROUP BY competitor_pricelist_id", + } + for _, query := range usageQueries { + usageRows = usageRows[:0] + if err := r.db.Raw(query, ids).Scan(&usageRows).Error; err != nil { + continue + } + for _, row := range usageRows { + usageCounts[row.PricelistID] += row.Count + } + } + summaries := make([]models.PricelistSummary, len(pricelists)) for i, pl := range pricelists { - var itemCount int64 - r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount) - usageCount, _ := r.CountUsage(pl.ID) - summaries[i] = models.PricelistSummary{ ID: pl.ID, Source: pl.Source, @@ -101,9 +141,9 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model CreatedAt: pl.CreatedAt, CreatedBy: pl.CreatedBy, IsActive: pl.IsActive, - UsageCount: int(usageCount), + UsageCount: int(usageCounts[pl.ID]), ExpiresAt: pl.ExpiresAt, - ItemCount: itemCount, + ItemCount: itemCounts[pl.ID], } } diff --git a/web/static/js/admin_pricing.js b/web/static/js/admin_pricing.js index 91a3fb5..a427b72 100644 --- a/web/static/js/admin_pricing.js +++ b/web/static/js/admin_pricing.js @@ -26,6 +26,8 @@ let compInlineSortField = 'popularity_score'; let compInlineSortDir = 'desc'; let compInlineSearchTimer = null; let b2bCompetitorsList = []; +let previewAbortController = null; +let previewRequestSeq = 0; function getPricelistTabConfig(tab) { if (tab === 'estimate') return { source: 'estimate', title: 'Estimate' }; @@ -127,7 +129,7 @@ async function loadTab(tab) { compInlinePage = 1; compInlineSearch = ''; const searchEl = document.getElementById('comp-inline-search'); if (searchEl) searchEl.value = ''; - await fetch('/api/admin/pricing/lots/sync-metadata', { method: 'POST' }).catch(() => {}); + fetch('/api/admin/pricing/lots/sync-metadata', { method: 'POST' }).catch(() => {}); await loadCompInline(); } else if (tab === 'warehouse') { await loadStockLotOptions(); @@ -399,6 +401,128 @@ function escapeHtml(text) { return div.innerHTML; } +function formatChartPrice(value) { + if (!Number.isFinite(value)) return '—'; + const num = Number(value); + if (Math.abs(num) >= 1000) { + const compact = (num / 1000).toFixed(1).replace('.', ',').replace(/,0$/, ''); + return '$' + compact + 'k'; + } + const rounded = Math.round(num * 10) / 10; + const text = Number.isInteger(rounded) ? String(Math.trunc(rounded)) : String(rounded).replace('.', ','); + return '$' + text; +} + +function resetPriceChart(message) { + const chartEl = document.getElementById('modal-price-chart'); + if (!chartEl) return; + chartEl.innerHTML = `
${escapeHtml(message || 'Нет данных')}
`; +} + +function renderPriceChart(history, currentPrice) { + const chartEl = document.getElementById('modal-price-chart'); + if (!chartEl) return; + + const rows = Array.isArray(history) ? history.filter(item => item && item.date && Number.isFinite(Number(item.price))) : []; + const current = Number(currentPrice); + const hasCurrent = Number.isFinite(current) && current > 0; + + if (rows.length === 0 && !hasCurrent) { + resetPriceChart('Нет данных для графика'); + return; + } + + const parsed = rows.map(item => ({ + date: String(item.date), + ts: new Date(item.date).getTime(), + price: Number(item.price) + })).filter(item => Number.isFinite(item.ts) && Number.isFinite(item.price)); + + if (parsed.length === 0 && !hasCurrent) { + resetPriceChart('Нет данных для графика'); + return; + } + + const width = 560; + const height = 176; + const padLeft = 46; + const padRight = 12; + const padTop = 12; + const padBottom = 28; + const plotWidth = width - padLeft - padRight; + const plotHeight = height - padTop - padBottom; + + let minTs = parsed.length > 0 ? parsed[0].ts : Date.now(); + let maxTs = parsed.length > 0 ? parsed[parsed.length - 1].ts : minTs; + if (minTs === maxTs) { + minTs -= 12 * 60 * 60 * 1000; + maxTs += 12 * 60 * 60 * 1000; + } + + const priceValues = parsed.map(item => item.price); + if (hasCurrent) priceValues.push(current); + let minPrice = Math.min(...priceValues); + let maxPrice = Math.max(...priceValues); + if (minPrice === maxPrice) { + minPrice = Math.max(0, minPrice - 1); + maxPrice = maxPrice + 1; + } + const pricePad = Math.max((maxPrice - minPrice) * 0.12, 1); + minPrice = Math.max(0, minPrice - pricePad); + maxPrice = maxPrice + pricePad; + + const scaleX = ts => padLeft + ((ts - minTs) / (maxTs - minTs)) * plotWidth; + const scaleY = price => padTop + plotHeight - ((price - minPrice) / (maxPrice - minPrice)) * plotHeight; + + const quotePoints = parsed.map(item => `${scaleX(item.ts).toFixed(2)},${scaleY(item.price).toFixed(2)}`).join(' '); + const pointMarkers = parsed.map(item => { + const x = scaleX(item.ts).toFixed(2); + const y = scaleY(item.price).toFixed(2); + const title = `${new Date(item.ts).toLocaleDateString('ru-RU')}: ${formatChartPrice(item.price)}`; + return ` + + ${escapeHtml(title)} + + `; + }).join(''); + const currentY = hasCurrent ? scaleY(current).toFixed(2) : null; + const startDate = new Date(minTs).toLocaleDateString('ru-RU'); + const endDate = new Date(maxTs).toLocaleDateString('ru-RU'); + const midPrice = (minPrice + maxPrice) / 2; + const tickCount = parsed.length > 1 ? 4 : 2; + const xTicks = []; + for (let i = 0; i < tickCount; i++) { + const ratio = tickCount === 1 ? 0 : i / (tickCount - 1); + const ts = minTs + (maxTs - minTs) * ratio; + const x = scaleX(ts).toFixed(2); + const label = new Date(ts).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: i === 0 || i === tickCount - 1 ? '2-digit' : undefined }); + xTicks.push(` + + ${escapeHtml(label)} + `); + } + + chartEl.innerHTML = ` + + + + + + + + ${xTicks.join('')} + ${hasCurrent ? `` : ''} + ${quotePoints ? `` : ''} + ${pointMarkers} + ${escapeHtml(formatChartPrice(maxPrice))} + ${escapeHtml(formatChartPrice(midPrice))} + ${escapeHtml(formatChartPrice(minPrice))} + ${escapeHtml(startDate)} + ${escapeHtml(endDate)} + + `; +} + // Modal functions function openModal(idx) { const c = componentsCache[idx]; @@ -447,6 +571,7 @@ function openModal(idx) { document.getElementById('modal-current-price').textContent = '...'; document.getElementById('modal-new-price').textContent = '...'; document.getElementById('modal-quote-count').textContent = '...'; + resetPriceChart('Загрузка...'); document.getElementById('price-modal').classList.remove('hidden'); document.getElementById('price-modal').classList.add('flex'); @@ -470,6 +595,12 @@ function onMethodChange() { } async function fetchPreview() { + if (previewAbortController) { + previewAbortController.abort(); + } + previewAbortController = new AbortController(); + const requestSeq = ++previewRequestSeq; + const lotName = document.getElementById('modal-lot-name').value; const method = document.getElementById('modal-method').value; const periodDays = parseInt(document.getElementById('modal-period').value) || 0; @@ -491,6 +622,7 @@ async function fetchPreview() { headers: { 'Content-Type': 'application/json' }, + signal: previewAbortController.signal, body: JSON.stringify({ lot_name: lotName, method: method, @@ -505,6 +637,7 @@ async function fetchPreview() { if (resp.ok) { const data = await resp.json(); + if (requestSeq !== previewRequestSeq) return; // Update last price with date if (data.last_price) { @@ -545,17 +678,24 @@ async function fetchPreview() { quoteCountText = data.quote_count || 0; } document.getElementById('modal-quote-count').textContent = quoteCountText; + renderPriceChart(data.estimate_history || [], data.current_price); } } catch(e) { + if (e.name === 'AbortError') return; console.error('Preview fetch error:', e); document.getElementById('modal-last-price').textContent = '—'; document.getElementById('modal-median-all').textContent = '—'; document.getElementById('modal-current-price').textContent = '—'; document.getElementById('modal-new-price').textContent = '—'; + resetPriceChart('Не удалось загрузить график'); } } function closeModal() { + if (previewAbortController) { + previewAbortController.abort(); + previewAbortController = null; + } document.getElementById('price-modal').classList.add('hidden'); document.getElementById('price-modal').classList.remove('flex'); } @@ -640,7 +780,11 @@ async function savePrice() { if (resp.ok) { closeModal(); - loadData(); + if (currentTab === 'estimate') { + await loadCompInline(); + } else { + await loadData(); + } } else { const data = await resp.json(); alert('Ошибка: ' + (data.error || 'Неизвестная ошибка')); diff --git a/web/templates/admin_pricing.html b/web/templates/admin_pricing.html index 0363f0f..2e49164 100644 --- a/web/templates/admin_pricing.html +++ b/web/templates/admin_pricing.html @@ -507,6 +507,23 @@ Кол-во котировок: + +
+
График цены
+ +
+ + + Котировки + + + + Текущая цена + +
+