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:
16
CLAUDE.md
16
CLAUDE.md
@@ -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
121
MIGRATION_PRICE_REFRESH.md
Normal 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;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Внимание:** После отмены миграции функционал пересчета цен перестанет работать корректно.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
4
migrations/004_add_price_updated_at.sql
Normal file
4
migrations/004_add_price_updated_at.sql
Normal 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;
|
||||||
@@ -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}}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user