From f31ae6923388e30ca66e75d621f9860f291d7d70 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sat, 31 Jan 2026 10:31:00 +0300 Subject: [PATCH] Add price refresh functionality to configurator - Add price_updated_at field to qt_configurations table to track when prices were last updated - Add RefreshPrices() method in configuration service to update all component prices with current values from database - Add POST /api/configs/:uuid/refresh-prices API endpoint for price updates - Add "Refresh Prices" button in configurator UI next to Save button - Display last price update timestamp in human-readable format (e.g., "5 min ago", "2 hours ago") - Create migration 004_add_price_updated_at.sql for database schema update - Update CLAUDE.md documentation with new API endpoint and schema changes - Add MIGRATION_PRICE_REFRESH.md with detailed migration instructions Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 16 ++-- MIGRATION_PRICE_REFRESH.md | 121 ++++++++++++++++++++++++ cmd/server/main.go | 1 + internal/handlers/configuration.go | 19 ++++ internal/models/configuration.go | 23 ++--- internal/services/configuration.go | 53 +++++++++++ migrations/004_add_price_updated_at.sql | 4 + web/templates/index.html | 91 +++++++++++++++++- 8 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 MIGRATION_PRICE_REFRESH.md create mode 100644 migrations/004_add_price_updated_at.sql diff --git a/CLAUDE.md b/CLAUDE.md index e851f04..90bcda6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,8 +118,11 @@ CREATE TABLE qt_configurations ( name VARCHAR(200) NOT NULL, items JSON NOT NULL, -- [{"lot_name": "CPU_AMD_9654", "quantity": 2, "unit_price": 11500}] total_price DECIMAL(12,2), + custom_price DECIMAL(12,2), -- User-defined target price (for discounts) notes TEXT, is_template BOOLEAN DEFAULT FALSE, + server_count INT DEFAULT 1, -- Number of servers in configuration + price_updated_at TIMESTAMP, -- Last time prices were refreshed created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES qt_users(id) ); @@ -268,12 +271,13 @@ POST /api/export/xlsx → {"items": [...], "name": "Config 1"} → XLSX file ### Configurations ``` -GET /api/configs → list user's configurations -POST /api/configs → save new configuration -GET /api/configs/:uuid → get by UUID -PUT /api/configs/:uuid → update -DELETE /api/configs/:uuid → delete -GET /api/configs/:uuid/export → export as JSON +GET /api/configs → list user's configurations +POST /api/configs → save new configuration +GET /api/configs/:uuid → get by UUID +PUT /api/configs/:uuid → update +POST /api/configs/:uuid/refresh-prices → refresh prices for all components +DELETE /api/configs/:uuid → delete +GET /api/configs/:uuid/export → export as JSON ``` ### Pricing Admin (requires role: pricing_admin or admin) diff --git a/MIGRATION_PRICE_REFRESH.md b/MIGRATION_PRICE_REFRESH.md new file mode 100644 index 0000000..f97a3f5 --- /dev/null +++ b/MIGRATION_PRICE_REFRESH.md @@ -0,0 +1,121 @@ +# Миграция: Функционал пересчета цен в конфигураторе + +## Описание изменений + +Добавлен функционал автоматического обновления цен компонентов в сохраненных конфигурациях. + +### Новые возможности + +1. **Кнопка "Пересчитать цену"** на странице конфигуратора + - Обновляет цены всех компонентов в конфигурации до актуальных значений из базы данных + - Сохраняет количество компонентов, обновляя только цены + - Отображает время последнего обновления цен + +2. **Поле `price_updated_at`** в таблице конфигураций + - Хранит дату и время последнего обновления цен + - Отображается на странице конфигуратора в удобном формате ("5 мин. назад", "2 ч. назад" и т.д.) + +### Изменения в базе данных + +Добавлено новое поле в таблицу `qt_configurations`: +```sql +ALTER TABLE qt_configurations +ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL +AFTER server_count; +``` + +### Новый API endpoint + +``` +POST /api/configs/:uuid/refresh-prices +``` + +**Требования:** +- Авторизация: Bearer Token +- Роль: editor или выше + +**Ответ:** +```json +{ + "id": 1, + "uuid": "...", + "name": "Конфигурация 1", + "items": [ + { + "lot_name": "CPU_AMD_9654", + "quantity": 2, + "unit_price": 11500.00 + } + ], + "total_price": 23000.00, + "price_updated_at": "2026-01-31T12:34:56Z", + ... +} +``` + +## Применение изменений + +### 1. Обновление базы данных + +Запустите сервер с флагом миграции: +```bash +./quoteforge -migrate -config config.yaml +``` + +Или выполните SQL миграцию вручную: +```bash +mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql +``` + +### 2. Перезапуск сервера + +После применения миграции перезапустите сервер: +```bash +./quoteforge -config config.yaml +``` + +## Использование + +1. Откройте любую сохраненную конфигурацию в конфигураторе +2. Нажмите кнопку **"Пересчитать цену"** рядом с кнопкой "Сохранить" +3. Все цены компонентов будут обновлены до актуальных значений +4. Конфигурация автоматически сохраняется с обновленными ценами +5. Под кнопками отображается время последнего обновления цен + +## Технические детали + +### Измененные файлы + +- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt` +- `internal/services/configuration.go` - добавлен метод `RefreshPrices()` +- `internal/handlers/configuration.go` - добавлен обработчик `RefreshPrices()` +- `cmd/server/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices` +- `web/templates/index.html` - добавлена кнопка и JavaScript функции +- `migrations/004_add_price_updated_at.sql` - SQL миграция +- `CLAUDE.md` - обновлена документация + +### Логика обновления цен + +1. Получение конфигурации по UUID +2. Проверка прав доступа (пользователь должен быть владельцем) +3. Для каждого компонента в конфигурации: + - Получение актуальной цены из `qt_lot_metadata.current_price` + - Обновление `unit_price` в items +4. Пересчет `total_price` с учетом `server_count` +5. Установка `price_updated_at` на текущее время +6. Сохранение конфигурации + +### Обработка ошибок + +- Если компонент не найден или у него нет цены - сохраняется старая цена +- При ошибках доступа возвращается 403 Forbidden +- При отсутствии конфигурации возвращается 404 Not Found + +## Отмена изменений (Rollback) + +Для отмены миграции выполните: +```sql +ALTER TABLE qt_configurations DROP COLUMN price_updated_at; +``` + +**Внимание:** После отмены миграции функционал пересчета цен перестанет работать корректно. diff --git a/cmd/server/main.go b/cmd/server/main.go index b9dd743..b292b25 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -295,6 +295,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) { configs.PUT("/:uuid", configHandler.Update) configs.PATCH("/:uuid/rename", configHandler.Rename) configs.POST("/:uuid/clone", configHandler.Clone) + configs.POST("/:uuid/refresh-prices", configHandler.RefreshPrices) 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 a96854b..080621e 100644 --- a/internal/handlers/configuration.go +++ b/internal/handlers/configuration.go @@ -181,6 +181,25 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) { c.JSON(http.StatusCreated, config) } +func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) { + userID := middleware.GetUserID(c) + uuid := c.Param("uuid") + + config, err := h.configService.RefreshPrices(uuid, userID) + 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") diff --git a/internal/models/configuration.go b/internal/models/configuration.go index 04bf57d..4cbda68 100644 --- a/internal/models/configuration.go +++ b/internal/models/configuration.go @@ -40,17 +40,18 @@ func (c ConfigItems) Total() float64 { } type Configuration struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` - UserID uint `gorm:"not null" json:"user_id"` - Name string `gorm:"size:200;not null" json:"name"` - Items ConfigItems `gorm:"type:json;not null" json:"items"` - TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"` - CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"` - Notes string `gorm:"type:text" json:"notes"` - IsTemplate bool `gorm:"default:false" json:"is_template"` - ServerCount int `gorm:"default:1" json:"server_count"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` + UserID uint `gorm:"not null" json:"user_id"` + Name string `gorm:"size:200;not null" json:"name"` + Items ConfigItems `gorm:"type:json;not null" json:"items"` + TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"` + CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"` + Notes string `gorm:"type:text" json:"notes"` + IsTemplate bool `gorm:"default:false" json:"is_template"` + ServerCount int `gorm:"default:1" json:"server_count"` + PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` } diff --git a/internal/services/configuration.go b/internal/services/configuration.go index 0585ef4..764073c 100644 --- a/internal/services/configuration.go +++ b/internal/services/configuration.go @@ -2,6 +2,7 @@ package services import ( "errors" + "time" "github.com/google/uuid" "git.mchus.pro/mchus/quoteforge/internal/models" @@ -205,6 +206,58 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config return s.configRepo.ListTemplates(offset, perPage) } +// RefreshPrices updates all component prices in the configuration with current prices +func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) { + config, err := s.configRepo.GetByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + if config.UserID != userID { + return nil, ErrConfigForbidden + } + + // Update prices for all items + updatedItems := make(models.ConfigItems, len(config.Items)) + for i, item := range config.Items { + // Get current component price + metadata, err := s.componentRepo.GetByLotName(item.LotName) + if err != nil || metadata.CurrentPrice == nil { + // Keep original item if component not found or no price available + updatedItems[i] = item + continue + } + + // Update item with current price + updatedItems[i] = models.ConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: *metadata.CurrentPrice, + } + } + + // Update configuration + config.Items = updatedItems + total := updatedItems.Total() + + // If server count is greater than 1, multiply the total by server count + if config.ServerCount > 1 { + total *= float64(config.ServerCount) + } + + config.TotalPrice = &total + + // Set price update timestamp + now := time.Now() + config.PriceUpdatedAt = &now + + if err := s.configRepo.Update(config); err != nil { + return nil, err + } + + return config, nil +} + // // Export configuration as JSON // type ConfigExport struct { // Name string `json:"name"` diff --git a/migrations/004_add_price_updated_at.sql b/migrations/004_add_price_updated_at.sql new file mode 100644 index 0000000..b6077fb --- /dev/null +++ b/migrations/004_add_price_updated_at.sql @@ -0,0 +1,4 @@ +-- Add price_updated_at column to qt_configurations table +ALTER TABLE qt_configurations +ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL +AFTER server_count; diff --git a/web/templates/index.html b/web/templates/index.html index d2de32e..dde4c02 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -14,10 +14,14 @@ Конфигуратор - @@ -315,6 +319,11 @@ document.addEventListener('DOMContentLoaded', async function() { if (config.custom_price) { document.getElementById('custom-price-input').value = config.custom_price.toFixed(2); } + + // Display price update date if available + if (config.price_updated_at) { + updatePriceUpdateDate(config.price_updated_at); + } } catch(e) { showToast('Ошибка загрузки конфигурации', 'error'); window.location.href = '/configs'; @@ -1298,6 +1307,86 @@ async function exportCSVWithCustomPrice() { } } +async function refreshPrices() { + const token = localStorage.getItem('token'); + if (!token || !configUUID) return; + + try { + const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' + } + }); + + if (resp.status === 401) { + window.location.href = '/login'; + return; + } + + if (!resp.ok) { + showToast('Ошибка обновления цен', 'error'); + return; + } + + const config = await resp.json(); + + // Update cart with new prices + if (config.items && config.items.length > 0) { + cart = config.items.map(item => ({ + lot_name: item.lot_name, + quantity: item.quantity, + unit_price: item.unit_price, + description: item.description || '', + category: item.category || getCategoryFromLotName(item.lot_name) + })); + } + + // Update price update date + if (config.price_updated_at) { + updatePriceUpdateDate(config.price_updated_at); + } + + // Re-render UI + renderTab(); + updateCartUI(); + + showToast('Цены обновлены', 'success'); + } catch(e) { + showToast('Ошибка обновления цен', 'error'); + } +} + +function updatePriceUpdateDate(dateStr) { + if (!dateStr) { + document.getElementById('price-update-date').textContent = ''; + return; + } + + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + let timeAgo; + if (diffMins < 1) { + timeAgo = 'только что'; + } else if (diffMins < 60) { + timeAgo = diffMins + ' мин. назад'; + } else if (diffHours < 24) { + timeAgo = diffHours + ' ч. назад'; + } else if (diffDays < 7) { + timeAgo = diffDays + ' дн. назад'; + } else { + timeAgo = date.toLocaleDateString('ru-RU'); + } + + document.getElementById('price-update-date').textContent = 'Обновлено: ' + timeAgo; +} + {{end}}