From 7ded78f2c3b033976027f57b6124372bf107b541 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 27 Jan 2026 11:39:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=86=D0=B5=D0=BD=D0=B0=D0=BC=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлено отображение последней полученной цены в окне настройки цены - Добавлен функционал переименования конфигураций (PATCH /api/configs/:uuid/rename) - Изменён формат имени файла при экспорте: "YYYY-MM-DD NAME SPEC.ext" - Исправлена сортировка компонентов: перенесена на сервер для корректной работы с пагинацией - Добавлен расчёт popularity_score на основе котировок из lot_log - Исправлена потеря настроек (метод, период, коэффициент) при пересчёте цен Co-Authored-By: Claude Opus 4.5 --- cmd/server/main.go | 1 + internal/handlers/configuration.go | 39 +++++++++++- internal/handlers/export.go | 8 +-- internal/handlers/pricing.go | 53 ++++++++-------- internal/repository/component.go | 37 +++++++++-- internal/repository/stats.go | 23 +++++++ internal/services/configuration.go | 19 ++++++ web/templates/admin_pricing.html | 63 +++++++++---------- web/templates/configs.html | 99 +++++++++++++++++++++++++++++- 9 files changed, 269 insertions(+), 73 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index ab67340..f19ce53 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -293,6 +293,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) { configs.POST("", configHandler.Create) configs.GET("/:uuid", configHandler.Get) configs.PUT("/:uuid", configHandler.Update) + configs.PATCH("/:uuid/rename", configHandler.Rename) configs.DELETE("/:uuid", configHandler.Delete) configs.GET("/:uuid/export", configHandler.ExportJSON) configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV) diff --git a/internal/handlers/configuration.go b/internal/handlers/configuration.go index dcd4f9e..3b5d691 100644 --- a/internal/handlers/configuration.go +++ b/internal/handlers/configuration.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "io" "net/http" "strconv" @@ -123,17 +124,53 @@ func (h *ConfigurationHandler) Delete(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "deleted"}) } +type RenameConfigRequest struct { + Name string `json:"name" binding:"required"` +} + +func (h *ConfigurationHandler) Rename(c *gin.Context) { + userID := middleware.GetUserID(c) + uuid := c.Param("uuid") + + var req RenameConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + config, err := h.configService.Rename(uuid, userID, req.Name) + if err != nil { + status := http.StatusInternalServerError + if err == services.ErrConfigNotFound { + status = http.StatusNotFound + } else if err == services.ErrConfigForbidden { + status = http.StatusForbidden + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, config) +} + func (h *ConfigurationHandler) ExportJSON(c *gin.Context) { userID := middleware.GetUserID(c) uuid := c.Param("uuid") + config, err := h.configService.GetByUUID(uuid, userID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + data, err := h.configService.ExportJSON(uuid, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } - c.Header("Content-Disposition", "attachment; filename=config.json") + filename := fmt.Sprintf("%s %s SPEC.json", config.CreatedAt.Format("2006-01-02"), config.Name) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) c.Data(http.StatusOK, "application/json", data) } diff --git a/internal/handlers/export.go b/internal/handlers/export.go index ec344dd..4a78cf0 100644 --- a/internal/handlers/export.go +++ b/internal/handlers/export.go @@ -54,8 +54,8 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) { return } - filename := fmt.Sprintf("%s_%s.csv", req.Name, time.Now().Format("20060102")) - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData) } @@ -101,8 +101,8 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) { return } - filename := fmt.Sprintf("%s_%s.csv", config.Name, config.CreatedAt.Format("20060102")) - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData) } diff --git a/internal/handlers/pricing.go b/internal/handlers/pricing.go index 86cc12c..4c9f251 100644 --- a/internal/handlers/pricing.go +++ b/internal/handlers/pricing.go @@ -62,8 +62,10 @@ func (h *PricingHandler) ListComponents(c *gin.Context) { perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) filter := repository.ComponentFilter{ - Category: c.Query("category"), - Search: c.Query("search"), + Category: c.Query("category"), + Search: c.Query("search"), + SortField: c.Query("sort"), + SortDir: c.Query("dir"), } if page < 1 { @@ -203,7 +205,6 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) { } periodDays := comp.PricePeriodDays - usedAllTime := false var result struct { Price *float64 } @@ -213,13 +214,10 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) { query := `SELECT AVG(price) as price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)` h.db.Raw(query, lotName, periodDays).Scan(&result) - // If no prices found in period, fall back to all time + // If no prices found in period, fall back to all time but keep user's period setting if result.Price == nil || *result.Price <= 0 { query = `SELECT AVG(price) as price FROM lot_log WHERE lot = ?` h.db.Raw(query, lotName).Scan(&result) - if result.Price != nil && *result.Price > 0 { - usedAllTime = true - } } } else { query := `SELECT AVG(price) as price FROM lot_log WHERE lot = ?` @@ -236,19 +234,13 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) { } now := time.Now() - updates := map[string]interface{}{ - "current_price": finalPrice, - "price_updated_at": now, - } - - // If we fell back to all time, update the period setting - if usedAllTime { - updates["price_period_days"] = 0 - } - + // Only update price, preserve all user settings h.db.Model(&models.LotMetadata{}). Where("lot_name = ?", lotName). - Updates(updates) + Updates(map[string]interface{}{ + "current_price": finalPrice, + "price_updated_at": now, + }) } func (h *PricingHandler) RecalculateAll(c *gin.Context) { @@ -339,22 +331,20 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) { continue } - // Determine which price to use + // Determine which price to use based on component settings var finalPrice float64 - usedAllTime := false periodDays := comp.PricePeriodDays if periodDays <= 0 { - // Already using all time + // Use all time finalPrice = *priceData.AvgPrice } else { // Try period price first (using 90-day as proxy) if priceData.AvgPrice90 != nil && *priceData.AvgPrice90 > 0 { finalPrice = *priceData.AvgPrice90 } else { - // Fall back to all time + // Fall back to all time if no data in period, but keep user's period setting finalPrice = *priceData.AvgPrice - usedAllTime = true } } @@ -363,15 +353,12 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) { finalPrice = finalPrice * (1 + comp.PriceCoefficient/100) } + // Only update current_price and price_updated_at, preserve all other settings updates := map[string]interface{}{ "current_price": finalPrice, "price_updated_at": now, } - if usedAllTime { - updates["price_period_days"] = 0 - } - err := tx.Model(&models.LotMetadata{}). Where("lot_name = ?", comp.LotName). Updates(updates).Error @@ -400,6 +387,9 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) { c.Writer.Flush() } + // Update popularity scores + h.statsRepo.UpdatePopularityScores() + // Send completion c.SSEvent("progress", gin.H{ "current": updated + skipped + manual + errors, @@ -514,6 +504,13 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) { var quoteCount int64 h.db.Model(&models.LotLog{}).Where("lot = ?", req.LotName).Count("eCount) + // Get last received price + 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 var basePrice *float64 if req.PeriodDays > 0 { @@ -542,5 +539,7 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) { "new_price": newPrice, "quote_count": quoteCount, "manual_price": comp.ManualPrice, + "last_price": lastPrice.Price, + "last_price_date": lastPrice.Date, }) } diff --git a/internal/repository/component.go b/internal/repository/component.go index e6b72f1..67f4159 100644 --- a/internal/repository/component.go +++ b/internal/repository/component.go @@ -16,9 +16,11 @@ func NewComponentRepository(db *gorm.DB) *ComponentRepository { } type ComponentFilter struct { - Category string - Search string - HasPrice bool + Category string + Search string + HasPrice bool + SortField string + SortDir string } func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) { @@ -43,10 +45,33 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([ query.Count(&total) - // Sort by popularity + freshness, no price goes last + // Apply sorting + sortDir := "ASC" + if filter.SortDir == "desc" { + sortDir = "DESC" + } + + switch filter.SortField { + case "popularity_score": + query = query.Order("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) + case "lot_name": + query = query.Order("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"). + 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") + } + err := query. - Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END"). - Order("popularity_score DESC"). Offset(offset). Limit(limit). Find(&components).Error diff --git a/internal/repository/stats.go b/internal/repository/stats.go index 3e1f636..df40ecd 100644 --- a/internal/repository/stats.go +++ b/internal/repository/stats.go @@ -90,3 +90,26 @@ func (r *StatsRepository) ResetMonthlyCounters() error { Where("1 = 1"). Update("quotes_last_30d", 0).Error } + +// UpdatePopularityScores recalculates popularity_score in qt_lot_metadata +// based on supplier quotes from lot_log table +func (r *StatsRepository) UpdatePopularityScores() error { + // Formula: popularity_score = quotes_last_30d * 3 + quotes_last_90d * 1 + quotes_total * 0.1 + // This gives more weight to recent supplier activity + return r.db.Exec(` + UPDATE qt_lot_metadata m + LEFT JOIN ( + SELECT + lot, + COUNT(*) as quotes_total, + SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as quotes_last_30d, + SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 90 DAY) THEN 1 ELSE 0 END) as quotes_last_90d + FROM lot_log + GROUP BY lot + ) s ON m.lot_name = s.lot + SET m.popularity_score = COALESCE( + s.quotes_last_30d * 3 + s.quotes_last_90d * 1 + s.quotes_total * 0.1, + 0 + ) + `).Error +} diff --git a/internal/services/configuration.go b/internal/services/configuration.go index f0c62ae..2106795 100644 --- a/internal/services/configuration.go +++ b/internal/services/configuration.go @@ -114,6 +114,25 @@ func (s *ConfigurationService) Delete(uuid string, userID uint) error { return s.configRepo.Delete(config.ID) } +func (s *ConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) { + config, err := s.configRepo.GetByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + if config.UserID != userID { + return nil, ErrConfigForbidden + } + + config.Name = newName + + if err := s.configRepo.Update(config); err != nil { + return nil, err + } + + return config, nil +} + func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) { if page < 1 { page = 1 diff --git a/web/templates/admin_pricing.html b/web/templates/admin_pricing.html index 44b5bd9..b6aefe5 100644 --- a/web/templates/admin_pricing.html +++ b/web/templates/admin_pricing.html @@ -113,6 +113,8 @@
Расчёт цены
+
Последняя цена:
+
Медиана (всё время):
Текущая цена:
@@ -180,6 +182,12 @@ async function loadData() { if (currentSearch) { url += '&search=' + encodeURIComponent(currentSearch); } + if (sortField) { + url += '&sort=' + encodeURIComponent(sortField); + } + if (sortDir) { + url += '&dir=' + encodeURIComponent(sortDir); + } const resp = await fetch(url, { headers: {'Authorization': 'Bearer ' + token} }); @@ -249,33 +257,6 @@ function renderComponents(components, total) { return; } - // Sort components locally - const sorted = [...components].sort((a, b) => { - let aVal, bVal; - switch (sortField) { - case 'popularity_score': - aVal = a.popularity_score || 0; - bVal = b.popularity_score || 0; - break; - case 'quote_count': - aVal = a.quote_count || 0; - bVal = b.quote_count || 0; - break; - case 'current_price': - aVal = a.current_price || 0; - bVal = b.current_price || 0; - break; - default: - aVal = a.lot_name || ''; - bVal = b.lot_name || ''; - } - if (sortDir === 'asc') { - return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; - } else { - return aVal < bVal ? 1 : aVal > bVal ? -1 : 0; - } - }); - let html = '
'; html += ''; html += ''; @@ -286,7 +267,7 @@ function renderComponents(components, total) { html += ''; html += ''; - sorted.forEach((c, idx) => { + components.forEach((c, idx) => { const price = c.current_price ? '$' + parseFloat(c.current_price).toLocaleString('en-US', {minimumFractionDigits: 2}) : '—'; const category = c.category ? c.category.code : '—'; const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : '—'; @@ -311,10 +292,7 @@ function renderComponents(components, total) { settings.push('РУЧН'); } - // Find original index in componentsCache - const origIdx = componentsCache.findIndex(x => x.lot_name === c.lot_name); - - html += ''; + html += ''; html += ''; html += ''; html += ''; @@ -352,6 +330,7 @@ function openModal(idx) { document.getElementById('modal-manual-price').disabled = !hasManual; // Reset price displays while loading + document.getElementById('modal-last-price').textContent = '...'; document.getElementById('modal-median-all').textContent = '...'; document.getElementById('modal-current-price').textContent = '...'; document.getElementById('modal-new-price').textContent = '...'; @@ -393,6 +372,18 @@ async function fetchPreview() { if (resp.ok) { const data = await resp.json(); + // Update last price with date + if (data.last_price) { + let lastPriceText = '$' + parseFloat(data.last_price).toFixed(2); + if (data.last_price_date) { + const date = new Date(data.last_price_date); + lastPriceText += ' (' + date.toLocaleDateString('ru-RU') + ')'; + } + document.getElementById('modal-last-price').textContent = lastPriceText; + } else { + document.getElementById('modal-last-price').textContent = '—'; + } + // Update median all time document.getElementById('modal-median-all').textContent = data.median_all_time ? '$' + parseFloat(data.median_all_time).toFixed(2) : '—'; @@ -410,7 +401,9 @@ async function fetchPreview() { } } catch(e) { 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 = '—'; } } @@ -584,13 +577,15 @@ document.getElementById('price-modal').addEventListener('click', function(e) { function changeSort() { sortField = document.getElementById('sort-field').value; - renderComponents(componentsCache, componentsCache.length); + currentPage = 1; + loadData(); } function toggleSortDir() { sortDir = sortDir === 'asc' ? 'desc' : 'asc'; document.getElementById('sort-dir-btn').textContent = sortDir === 'asc' ? '↑' : '↓'; - renderComponents(componentsCache, componentsCache.length); + currentPage = 1; + loadData(); } document.addEventListener('DOMContentLoaded', () => { diff --git a/web/templates/configs.html b/web/templates/configs.html index 8a58d0c..4b7ab9b 100644 --- a/web/templates/configs.html +++ b/web/templates/configs.html @@ -39,6 +39,31 @@ + + +
АртикулКатегорияНастройки
' + escapeHtml(c.lot_name) + '' + escapeHtml(category) + '' + escapeHtml(desc) + '