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 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 10:31:00 +03:00
parent 3132ab2fa2
commit f31ae69233
8 changed files with 310 additions and 18 deletions

View File

@@ -118,8 +118,11 @@ CREATE TABLE qt_configurations (
name VARCHAR(200) NOT NULL, name VARCHAR(200) NOT NULL,
items JSON NOT NULL, -- [{"lot_name": "CPU_AMD_9654", "quantity": 2, "unit_price": 11500}] items JSON NOT NULL, -- [{"lot_name": "CPU_AMD_9654", "quantity": 2, "unit_price": 11500}]
total_price DECIMAL(12,2), total_price DECIMAL(12,2),
custom_price DECIMAL(12,2), -- User-defined target price (for discounts)
notes TEXT, notes TEXT,
is_template BOOLEAN DEFAULT FALSE, 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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES qt_users(id) FOREIGN KEY (user_id) REFERENCES qt_users(id)
); );
@@ -268,12 +271,13 @@ POST /api/export/xlsx → {"items": [...], "name": "Config 1"} → XLSX file
### Configurations ### Configurations
``` ```
GET /api/configs → list user's configurations GET /api/configs → list user's configurations
POST /api/configs → save new configuration POST /api/configs → save new configuration
GET /api/configs/:uuid → get by UUID GET /api/configs/:uuid → get by UUID
PUT /api/configs/:uuid → update PUT /api/configs/:uuid → update
DELETE /api/configs/:uuid → delete POST /api/configs/:uuid/refresh-prices → refresh prices for all components
GET /api/configs/:uuid/export → export as JSON DELETE /api/configs/:uuid → delete
GET /api/configs/:uuid/export → export as JSON
``` ```
### Pricing Admin (requires role: pricing_admin or admin) ### Pricing Admin (requires role: pricing_admin or admin)

121
MIGRATION_PRICE_REFRESH.md Normal file
View File

@@ -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;
```
**Внимание:** После отмены миграции функционал пересчета цен перестанет работать корректно.

View File

@@ -295,6 +295,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
configs.PUT("/:uuid", configHandler.Update) configs.PUT("/:uuid", configHandler.Update)
configs.PATCH("/:uuid/rename", configHandler.Rename) configs.PATCH("/:uuid/rename", configHandler.Rename)
configs.POST("/:uuid/clone", configHandler.Clone) configs.POST("/:uuid/clone", configHandler.Clone)
configs.POST("/:uuid/refresh-prices", configHandler.RefreshPrices)
configs.DELETE("/:uuid", configHandler.Delete) configs.DELETE("/:uuid", configHandler.Delete)
// configs.GET("/:uuid/export", configHandler.ExportJSON) // configs.GET("/:uuid/export", configHandler.ExportJSON)
configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV) configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV)

View File

@@ -181,6 +181,25 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
c.JSON(http.StatusCreated, config) 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) { // func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
// userID := middleware.GetUserID(c) // userID := middleware.GetUserID(c)
// uuid := c.Param("uuid") // uuid := c.Param("uuid")

View File

@@ -40,17 +40,18 @@ func (c ConfigItems) Total() float64 {
} }
type Configuration struct { type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID uint `gorm:"not null" json:"user_id"` UserID uint `gorm:"not null" json:"user_id"`
Name string `gorm:"size:200;not null" json:"name"` Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"` Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"` TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"` CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"` Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"` IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"` ServerCount int `gorm:"default:1" json:"server_count"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` 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"` User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
} }

View File

@@ -2,6 +2,7 @@ package services
import ( import (
"errors" "errors"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"git.mchus.pro/mchus/quoteforge/internal/models" "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) 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 // // Export configuration as JSON
// type ConfigExport struct { // type ConfigExport struct {
// Name string `json:"name"` // Name string `json:"name"`

View File

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

View File

@@ -14,10 +14,14 @@
<span id="config-name">Конфигуратор</span> <span id="config-name">Конфигуратор</span>
</h1> </h1>
</div> </div>
<div id="save-buttons" class="hidden space-x-2"> <div id="save-buttons" class="hidden flex items-center space-x-2">
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Пересчитать цену
</button>
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"> <button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Сохранить Сохранить
</button> </button>
<span id="price-update-date" class="text-sm text-gray-500"></span>
</div> </div>
</div> </div>
@@ -315,6 +319,11 @@ document.addEventListener('DOMContentLoaded', async function() {
if (config.custom_price) { if (config.custom_price) {
document.getElementById('custom-price-input').value = config.custom_price.toFixed(2); 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) { } catch(e) {
showToast('Ошибка загрузки конфигурации', 'error'); showToast('Ошибка загрузки конфигурации', 'error');
window.location.href = '/configs'; 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;
}
</script> </script>
{{end}} {{end}}