Улучшения управления ценами и конфигурациями

- Добавлено отображение последней полученной цены в окне настройки цены
- Добавлен функционал переименования конфигураций (PATCH /api/configs/:uuid/rename)
- Изменён формат имени файла при экспорте: "YYYY-MM-DD NAME SPEC.ext"
- Исправлена сортировка компонентов: перенесена на сервер для корректной работы с пагинацией
- Добавлен расчёт popularity_score на основе котировок из lot_log
- Исправлена потеря настроек (метод, период, коэффициент) при пересчёте цен

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-01-27 11:39:12 +03:00
parent d7d6e9d62c
commit 7ded78f2c3
9 changed files with 269 additions and 73 deletions

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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(&quoteCount)
// 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,
})
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -113,6 +113,8 @@
<div class="bg-gray-50 p-3 rounded space-y-2">
<div class="text-sm font-medium text-gray-700 mb-2">Расчёт цены</div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="text-gray-600">Последняя цена:</div>
<div id="modal-last-price" class="font-medium text-right"></div>
<div class="text-gray-600">Медиана (всё время):</div>
<div id="modal-median-all" class="font-medium text-right"></div>
<div class="text-gray-600">Текущая цена:</div>
@@ -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 = '<div class="overflow-x-auto"><table class="w-full"><thead class="bg-gray-50"><tr>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>';
@@ -286,7 +267,7 @@ function renderComponents(components, total) {
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>';
html += '</tr></thead><tbody class="divide-y">';
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 += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + origIdx + ')">';
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + idx + ')">';
html += '<td class="px-3 py-2 text-sm font-mono">' + escapeHtml(c.lot_name) + '</td>';
html += '<td class="px-3 py-2 text-sm">' + escapeHtml(category) + '</td>';
html += '<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate">' + escapeHtml(desc) + '</td>';
@@ -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', () => {

View File

@@ -39,6 +39,31 @@
</div>
</div>
<!-- Modal for renaming configuration -->
<div id="rename-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Переименовать конфигурацию</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Новое название</label>
<input type="text" id="rename-input" placeholder="Введите новое название"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<input type="hidden" id="rename-uuid">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeRenameModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
Отмена
</button>
<button onclick="renameConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Сохранить
</button>
</div>
</div>
</div>
<script>
async function loadConfigs() {
const token = localStorage.getItem('token');
@@ -95,7 +120,8 @@ function renderConfigs(configs) {
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">';
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800">Переименовать</button>';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800">Удалить</button>';
html += '</td></tr>';
});
@@ -120,6 +146,63 @@ async function deleteConfig(uuid) {
loadConfigs();
}
function openRenameModal(uuid, currentName) {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('rename-uuid').value = uuid;
document.getElementById('rename-input').value = currentName;
document.getElementById('rename-modal').classList.remove('hidden');
document.getElementById('rename-modal').classList.add('flex');
document.getElementById('rename-input').focus();
document.getElementById('rename-input').select();
}
function closeRenameModal() {
document.getElementById('rename-modal').classList.add('hidden');
document.getElementById('rename-modal').classList.remove('flex');
}
async function renameConfig() {
const token = localStorage.getItem('token');
const uuid = document.getElementById('rename-uuid').value;
const name = document.getElementById('rename-input').value.trim();
if (!name) {
alert('Введите название');
return;
}
try {
const resp = await fetch('/api/configs/' + uuid + '/rename', {
method: 'PATCH',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: name })
});
if (resp.status === 401) {
logout();
return;
}
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось переименовать'));
return;
}
closeRenameModal();
loadConfigs();
} catch(e) {
alert('Ошибка переименования');
}
}
function openCreateModal() {
const token = localStorage.getItem('token');
if (!token) {
@@ -185,10 +268,24 @@ document.getElementById('create-modal').addEventListener('click', function(e) {
}
});
document.getElementById('rename-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeRenameModal();
}
});
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeRenameModal();
}
});
// Submit rename on Enter key
document.getElementById('rename-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
renameConfig();
}
});