- Fix nil pointer dereference in PricingHandler alert methods - Add automatic MariaDB connection on startup if settings exist - Update setupRouter to accept mariaDB as parameter - Fix offline mode checks: use h.db instead of h.alertService - Update setup handler to show restart required message - Add warning status support in setup.html UI This ensures that after saving connection settings, the application works correctly in online mode after restart. All repositories are properly initialized with MariaDB connection on startup. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
939 lines
25 KiB
Go
939 lines
25 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// calculateMedian returns the median of a sorted slice of prices
|
|
func calculateMedian(prices []float64) float64 {
|
|
if len(prices) == 0 {
|
|
return 0
|
|
}
|
|
sort.Float64s(prices)
|
|
n := len(prices)
|
|
if n%2 == 0 {
|
|
return (prices[n/2-1] + prices[n/2]) / 2
|
|
}
|
|
return prices[n/2]
|
|
}
|
|
|
|
// calculateAverage returns the arithmetic mean of prices
|
|
func calculateAverage(prices []float64) float64 {
|
|
if len(prices) == 0 {
|
|
return 0
|
|
}
|
|
var sum float64
|
|
for _, p := range prices {
|
|
sum += p
|
|
}
|
|
return sum / float64(len(prices))
|
|
}
|
|
|
|
type PricingHandler struct {
|
|
db *gorm.DB
|
|
pricingService *pricing.Service
|
|
alertService *alerts.Service
|
|
componentRepo *repository.ComponentRepository
|
|
priceRepo *repository.PriceRepository
|
|
statsRepo *repository.StatsRepository
|
|
}
|
|
|
|
func NewPricingHandler(
|
|
db *gorm.DB,
|
|
pricingService *pricing.Service,
|
|
alertService *alerts.Service,
|
|
componentRepo *repository.ComponentRepository,
|
|
priceRepo *repository.PriceRepository,
|
|
statsRepo *repository.StatsRepository,
|
|
) *PricingHandler {
|
|
return &PricingHandler{
|
|
db: db,
|
|
pricingService: pricingService,
|
|
alertService: alertService,
|
|
componentRepo: componentRepo,
|
|
priceRepo: priceRepo,
|
|
statsRepo: statsRepo,
|
|
}
|
|
}
|
|
|
|
func (h *PricingHandler) GetStats(c *gin.Context) {
|
|
// Check if we're in offline mode
|
|
if h.statsRepo == nil || h.alertService == nil {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"new_alerts_count": 0,
|
|
"top_components": []interface{}{},
|
|
"trending_components": []interface{}{},
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
newAlerts, _ := h.alertService.GetNewAlertsCount()
|
|
topComponents, _ := h.statsRepo.GetTopComponents(10)
|
|
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"new_alerts_count": newAlerts,
|
|
"top_components": topComponents,
|
|
"trending_components": trendingComponents,
|
|
})
|
|
}
|
|
|
|
type ComponentWithCount struct {
|
|
models.LotMetadata
|
|
QuoteCount int64 `json:"quote_count"`
|
|
UsedInMeta []string `json:"used_in_meta,omitempty"` // List of meta-articles that use this component
|
|
}
|
|
|
|
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
|
// Check if we're in offline mode
|
|
if h.componentRepo == nil {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"components": []ComponentWithCount{},
|
|
"total": 0,
|
|
"page": 1,
|
|
"per_page": 20,
|
|
"offline": true,
|
|
"message": "Управление ценами доступно только в онлайн режиме",
|
|
})
|
|
return
|
|
}
|
|
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
|
|
|
filter := repository.ComponentFilter{
|
|
Category: c.Query("category"),
|
|
Search: c.Query("search"),
|
|
SortField: c.Query("sort"),
|
|
SortDir: c.Query("dir"),
|
|
}
|
|
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 || perPage > 100 {
|
|
perPage = 20
|
|
}
|
|
offset := (page - 1) * perPage
|
|
|
|
components, total, err := h.componentRepo.List(filter, offset, perPage)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get quote counts
|
|
lotNames := make([]string, len(components))
|
|
for i, comp := range components {
|
|
lotNames[i] = comp.LotName
|
|
}
|
|
|
|
counts, _ := h.priceRepo.GetQuoteCounts(lotNames)
|
|
|
|
// Get meta usage information
|
|
metaUsage := h.getMetaUsageMap(lotNames)
|
|
|
|
// Combine components with counts
|
|
result := make([]ComponentWithCount, len(components))
|
|
for i, comp := range components {
|
|
result[i] = ComponentWithCount{
|
|
LotMetadata: comp,
|
|
QuoteCount: counts[comp.LotName],
|
|
UsedInMeta: metaUsage[comp.LotName],
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"components": result,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": perPage,
|
|
})
|
|
}
|
|
|
|
// getMetaUsageMap returns a map of lot_name -> list of meta-articles that use this component
|
|
func (h *PricingHandler) getMetaUsageMap(lotNames []string) map[string][]string {
|
|
result := make(map[string][]string)
|
|
|
|
// Get all components with meta_prices
|
|
var metaComponents []models.LotMetadata
|
|
h.db.Where("meta_prices IS NOT NULL AND meta_prices != ''").Find(&metaComponents)
|
|
|
|
// Build reverse lookup: which components are used in which meta-articles
|
|
for _, meta := range metaComponents {
|
|
sources := strings.Split(meta.MetaPrices, ",")
|
|
for _, source := range sources {
|
|
source = strings.TrimSpace(source)
|
|
if source == "" {
|
|
continue
|
|
}
|
|
|
|
// Handle wildcard patterns
|
|
if strings.HasSuffix(source, "*") {
|
|
prefix := strings.TrimSuffix(source, "*")
|
|
for _, lotName := range lotNames {
|
|
if strings.HasPrefix(lotName, prefix) && lotName != meta.LotName {
|
|
result[lotName] = append(result[lotName], meta.LotName)
|
|
}
|
|
}
|
|
} else {
|
|
// Direct match
|
|
for _, lotName := range lotNames {
|
|
if lotName == source && lotName != meta.LotName {
|
|
result[lotName] = append(result[lotName], meta.LotName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// expandMetaPrices expands meta_prices string to list of actual lot names
|
|
func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []string {
|
|
sources := strings.Split(metaPrices, ",")
|
|
var result []string
|
|
seen := make(map[string]bool)
|
|
|
|
for _, source := range sources {
|
|
source = strings.TrimSpace(source)
|
|
if source == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.HasSuffix(source, "*") {
|
|
// Wildcard pattern - find matching lots
|
|
prefix := strings.TrimSuffix(source, "*")
|
|
var matchingLots []string
|
|
h.db.Model(&models.LotMetadata{}).
|
|
Where("lot_name LIKE ? AND lot_name != ?", prefix+"%", excludeLot).
|
|
Pluck("lot_name", &matchingLots)
|
|
for _, lot := range matchingLots {
|
|
if !seen[lot] {
|
|
result = append(result, lot)
|
|
seen[lot] = true
|
|
}
|
|
}
|
|
} else if source != excludeLot && !seen[source] {
|
|
result = append(result, source)
|
|
seen[source] = true
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
|
// Check if we're in offline mode
|
|
if h.componentRepo == nil || h.pricingService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Управление ценами доступно только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
lotName := c.Param("lot_name")
|
|
|
|
component, err := h.componentRepo.GetByLotName(lotName)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
|
return
|
|
}
|
|
|
|
stats, err := h.pricingService.GetPriceStats(lotName, 0)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"component": component,
|
|
"price_stats": stats,
|
|
})
|
|
}
|
|
|
|
type UpdatePriceRequest struct {
|
|
LotName string `json:"lot_name" binding:"required"`
|
|
Method models.PriceMethod `json:"method"`
|
|
PeriodDays int `json:"period_days"`
|
|
Coefficient float64 `json:"coefficient"`
|
|
ManualPrice *float64 `json:"manual_price"`
|
|
ClearManual bool `json:"clear_manual"`
|
|
MetaEnabled bool `json:"meta_enabled"`
|
|
MetaPrices string `json:"meta_prices"`
|
|
MetaMethod string `json:"meta_method"`
|
|
MetaPeriod int `json:"meta_period"`
|
|
IsHidden bool `json:"is_hidden"`
|
|
}
|
|
|
|
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
|
// Check if we're in offline mode
|
|
if h.db == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Обновление цен доступно только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
var req UpdatePriceRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
updates := map[string]interface{}{}
|
|
|
|
// Update method if specified
|
|
if req.Method != "" {
|
|
updates["price_method"] = req.Method
|
|
}
|
|
|
|
// Update period days
|
|
if req.PeriodDays >= 0 {
|
|
updates["price_period_days"] = req.PeriodDays
|
|
}
|
|
|
|
// Update coefficient
|
|
updates["price_coefficient"] = req.Coefficient
|
|
|
|
// Handle meta prices
|
|
if req.MetaEnabled && req.MetaPrices != "" {
|
|
updates["meta_prices"] = req.MetaPrices
|
|
} else {
|
|
updates["meta_prices"] = ""
|
|
}
|
|
|
|
// Handle hidden flag
|
|
updates["is_hidden"] = req.IsHidden
|
|
|
|
// Handle manual price
|
|
if req.ClearManual {
|
|
updates["manual_price"] = nil
|
|
} else if req.ManualPrice != nil {
|
|
updates["manual_price"] = *req.ManualPrice
|
|
// Also update current price immediately when setting manual
|
|
updates["current_price"] = *req.ManualPrice
|
|
updates["price_updated_at"] = time.Now()
|
|
}
|
|
|
|
err := h.db.Model(&models.LotMetadata{}).
|
|
Where("lot_name = ?", req.LotName).
|
|
Updates(updates).Error
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Recalculate price if not using manual price
|
|
if req.ManualPrice == nil {
|
|
h.recalculateSinglePrice(req.LotName)
|
|
}
|
|
|
|
// Get updated component to return new price
|
|
var comp models.LotMetadata
|
|
h.db.Where("lot_name = ?", req.LotName).First(&comp)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "price updated",
|
|
"current_price": comp.CurrentPrice,
|
|
})
|
|
}
|
|
|
|
func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
|
var comp models.LotMetadata
|
|
if err := h.db.Where("lot_name = ?", lotName).First(&comp).Error; err != nil {
|
|
return
|
|
}
|
|
|
|
// Skip if manual price is set
|
|
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
|
return
|
|
}
|
|
|
|
periodDays := comp.PricePeriodDays
|
|
method := comp.PriceMethod
|
|
if method == "" {
|
|
method = models.PriceMethodMedian
|
|
}
|
|
|
|
// Determine which lot names to use for price calculation
|
|
lotNames := []string{lotName}
|
|
if comp.MetaPrices != "" {
|
|
lotNames = h.expandMetaPrices(comp.MetaPrices, lotName)
|
|
}
|
|
|
|
// Get prices based on period from all relevant lots
|
|
var prices []float64
|
|
for _, ln := range lotNames {
|
|
var lotPrices []float64
|
|
if strings.HasSuffix(ln, "*") {
|
|
pattern := strings.TrimSuffix(ln, "*") + "%"
|
|
if periodDays > 0 {
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
|
pattern, periodDays).Pluck("price", &lotPrices)
|
|
} else {
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
|
|
}
|
|
} else {
|
|
if periodDays > 0 {
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
|
ln, periodDays).Pluck("price", &lotPrices)
|
|
} else {
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
|
|
}
|
|
}
|
|
prices = append(prices, lotPrices...)
|
|
}
|
|
|
|
// If no prices in period, try all time
|
|
if len(prices) == 0 && periodDays > 0 {
|
|
for _, ln := range lotNames {
|
|
var lotPrices []float64
|
|
if strings.HasSuffix(ln, "*") {
|
|
pattern := strings.TrimSuffix(ln, "*") + "%"
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
|
|
} else {
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
|
|
}
|
|
prices = append(prices, lotPrices...)
|
|
}
|
|
}
|
|
|
|
if len(prices) == 0 {
|
|
return
|
|
}
|
|
|
|
// Calculate price based on method
|
|
sortFloat64s(prices)
|
|
var finalPrice float64
|
|
switch method {
|
|
case models.PriceMethodMedian:
|
|
finalPrice = calculateMedian(prices)
|
|
case models.PriceMethodAverage:
|
|
finalPrice = calculateAverage(prices)
|
|
default:
|
|
finalPrice = calculateMedian(prices)
|
|
}
|
|
|
|
if finalPrice <= 0 {
|
|
return
|
|
}
|
|
|
|
// Apply coefficient
|
|
if comp.PriceCoefficient != 0 {
|
|
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
|
}
|
|
|
|
now := time.Now()
|
|
// Only update price, preserve all user settings
|
|
h.db.Model(&models.LotMetadata{}).
|
|
Where("lot_name = ?", lotName).
|
|
Updates(map[string]interface{}{
|
|
"current_price": finalPrice,
|
|
"price_updated_at": now,
|
|
})
|
|
}
|
|
|
|
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
|
// Check if we're in offline mode
|
|
if h.db == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Пересчёт цен доступен только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Set headers for SSE
|
|
c.Header("Content-Type", "text/event-stream")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
|
|
// Get all components with their settings
|
|
var components []models.LotMetadata
|
|
h.db.Find(&components)
|
|
total := int64(len(components))
|
|
|
|
// Pre-load all lot names for efficient wildcard matching
|
|
var allLotNames []string
|
|
h.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames)
|
|
lotNameSet := make(map[string]bool, len(allLotNames))
|
|
for _, ln := range allLotNames {
|
|
lotNameSet[ln] = true
|
|
}
|
|
|
|
// Pre-load latest quote dates for all lots (for checking updates)
|
|
type LotDate struct {
|
|
Lot string
|
|
Date time.Time
|
|
}
|
|
var latestDates []LotDate
|
|
h.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates)
|
|
lotLatestDate := make(map[string]time.Time, len(latestDates))
|
|
for _, ld := range latestDates {
|
|
lotLatestDate[ld.Lot] = ld.Date
|
|
}
|
|
|
|
// Send initial progress
|
|
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"})
|
|
c.Writer.Flush()
|
|
|
|
// Process components individually to respect their settings
|
|
var updated, skipped, manual, unchanged, errors int
|
|
now := time.Now()
|
|
progressCounter := 0
|
|
|
|
for _, comp := range components {
|
|
progressCounter++
|
|
|
|
// If manual price is set, skip recalculation
|
|
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
|
manual++
|
|
goto sendProgress
|
|
}
|
|
|
|
// Calculate price based on component's individual settings
|
|
{
|
|
periodDays := comp.PricePeriodDays
|
|
method := comp.PriceMethod
|
|
if method == "" {
|
|
method = models.PriceMethodMedian
|
|
}
|
|
|
|
// Determine source lots for price calculation (using cached lot names)
|
|
var sourceLots []string
|
|
if comp.MetaPrices != "" {
|
|
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
|
|
} else {
|
|
sourceLots = []string{comp.LotName}
|
|
}
|
|
|
|
if len(sourceLots) == 0 {
|
|
skipped++
|
|
goto sendProgress
|
|
}
|
|
|
|
// Check if there are new quotes since last update (using cached dates)
|
|
if comp.PriceUpdatedAt != nil {
|
|
hasNewData := false
|
|
for _, lot := range sourceLots {
|
|
if latestDate, ok := lotLatestDate[lot]; ok {
|
|
if latestDate.After(*comp.PriceUpdatedAt) {
|
|
hasNewData = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !hasNewData {
|
|
unchanged++
|
|
goto sendProgress
|
|
}
|
|
}
|
|
|
|
// Get prices from source lots
|
|
var prices []float64
|
|
if periodDays > 0 {
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
|
sourceLots, periodDays).Pluck("price", &prices)
|
|
} else {
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
|
|
sourceLots).Pluck("price", &prices)
|
|
}
|
|
|
|
// If no prices in period, try all time
|
|
if len(prices) == 0 && periodDays > 0 {
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices)
|
|
}
|
|
|
|
if len(prices) == 0 {
|
|
skipped++
|
|
goto sendProgress
|
|
}
|
|
|
|
// Calculate price based on method
|
|
var basePrice float64
|
|
switch method {
|
|
case models.PriceMethodMedian:
|
|
basePrice = calculateMedian(prices)
|
|
case models.PriceMethodAverage:
|
|
basePrice = calculateAverage(prices)
|
|
default:
|
|
basePrice = calculateMedian(prices)
|
|
}
|
|
|
|
if basePrice <= 0 {
|
|
skipped++
|
|
goto sendProgress
|
|
}
|
|
|
|
finalPrice := basePrice
|
|
|
|
// Apply coefficient
|
|
if comp.PriceCoefficient != 0 {
|
|
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
|
}
|
|
|
|
// Update only price fields
|
|
err := h.db.Model(&models.LotMetadata{}).
|
|
Where("lot_name = ?", comp.LotName).
|
|
Updates(map[string]interface{}{
|
|
"current_price": finalPrice,
|
|
"price_updated_at": now,
|
|
}).Error
|
|
if err != nil {
|
|
errors++
|
|
} else {
|
|
updated++
|
|
}
|
|
}
|
|
|
|
sendProgress:
|
|
// Send progress update every 10 components to reduce overhead
|
|
if progressCounter%10 == 0 || progressCounter == int(total) {
|
|
c.SSEvent("progress", gin.H{
|
|
"current": updated + skipped + manual + unchanged + errors,
|
|
"total": total,
|
|
"updated": updated,
|
|
"skipped": skipped,
|
|
"manual": manual,
|
|
"unchanged": unchanged,
|
|
"errors": errors,
|
|
"status": "processing",
|
|
"lot_name": comp.LotName,
|
|
})
|
|
c.Writer.Flush()
|
|
}
|
|
}
|
|
|
|
// Update popularity scores
|
|
h.statsRepo.UpdatePopularityScores()
|
|
|
|
// Send completion
|
|
c.SSEvent("progress", gin.H{
|
|
"current": updated + skipped + manual + unchanged + errors,
|
|
"total": total,
|
|
"updated": updated,
|
|
"skipped": skipped,
|
|
"manual": manual,
|
|
"unchanged": unchanged,
|
|
"errors": errors,
|
|
"status": "completed",
|
|
})
|
|
c.Writer.Flush()
|
|
}
|
|
|
|
func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
|
// Check if we're in offline mode
|
|
if h.db == nil {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"alerts": []interface{}{},
|
|
"total": 0,
|
|
"page": 1,
|
|
"per_page": 20,
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
|
|
|
filter := repository.AlertFilter{
|
|
Status: models.AlertStatus(c.Query("status")),
|
|
Severity: models.AlertSeverity(c.Query("severity")),
|
|
Type: models.AlertType(c.Query("type")),
|
|
LotName: c.Query("lot_name"),
|
|
}
|
|
|
|
alertsList, total, err := h.alertService.List(filter, page, perPage)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"alerts": alertsList,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": perPage,
|
|
})
|
|
}
|
|
|
|
func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
|
|
// Check if we're in offline mode
|
|
if h.db == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Управление алертами доступно только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
|
return
|
|
}
|
|
|
|
if err := h.alertService.Acknowledge(uint(id)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "acknowledged"})
|
|
}
|
|
|
|
func (h *PricingHandler) ResolveAlert(c *gin.Context) {
|
|
// Check if we're in offline mode
|
|
if h.db == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Управление алертами доступно только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
|
return
|
|
}
|
|
|
|
if err := h.alertService.Resolve(uint(id)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "resolved"})
|
|
}
|
|
|
|
func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
|
|
// Check if we're in offline mode
|
|
if h.db == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Управление алертами доступно только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
|
return
|
|
}
|
|
|
|
if err := h.alertService.Ignore(uint(id)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "ignored"})
|
|
}
|
|
|
|
type PreviewPriceRequest struct {
|
|
LotName string `json:"lot_name" binding:"required"`
|
|
Method string `json:"method"`
|
|
PeriodDays int `json:"period_days"`
|
|
Coefficient float64 `json:"coefficient"`
|
|
MetaEnabled bool `json:"meta_enabled"`
|
|
MetaPrices string `json:"meta_prices"`
|
|
}
|
|
|
|
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
|
// Check if we're in offline mode
|
|
if h.db == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Предпросмотр цены доступен только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
var req PreviewPriceRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get component
|
|
var comp models.LotMetadata
|
|
if err := h.db.Where("lot_name = ?", req.LotName).First(&comp).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
|
return
|
|
}
|
|
|
|
// Determine which lot names to use for price calculation
|
|
lotNames := []string{req.LotName}
|
|
if req.MetaEnabled && req.MetaPrices != "" {
|
|
lotNames = h.expandMetaPrices(req.MetaPrices, req.LotName)
|
|
}
|
|
|
|
// Get all prices for calculations (from all relevant lots)
|
|
var allPrices []float64
|
|
for _, lotName := range lotNames {
|
|
var lotPrices []float64
|
|
if strings.HasSuffix(lotName, "*") {
|
|
// Wildcard pattern
|
|
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
|
|
} else {
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &lotPrices)
|
|
}
|
|
allPrices = append(allPrices, lotPrices...)
|
|
}
|
|
|
|
// Calculate median for all time
|
|
var medianAllTime *float64
|
|
if len(allPrices) > 0 {
|
|
sortFloat64s(allPrices)
|
|
median := calculateMedian(allPrices)
|
|
medianAllTime = &median
|
|
}
|
|
|
|
// Get quote count (from all relevant lots) - total count
|
|
var quoteCountTotal int64
|
|
for _, lotName := range lotNames {
|
|
var count int64
|
|
if strings.HasSuffix(lotName, "*") {
|
|
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
|
h.db.Model(&models.LotLog{}).Where("lot LIKE ?", pattern).Count(&count)
|
|
} else {
|
|
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count)
|
|
}
|
|
quoteCountTotal += count
|
|
}
|
|
|
|
// Get quote count for specified period (if period is > 0)
|
|
var quoteCountPeriod int64
|
|
if req.PeriodDays > 0 {
|
|
for _, lotName := range lotNames {
|
|
var count int64
|
|
if strings.HasSuffix(lotName, "*") {
|
|
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
|
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, pattern, req.PeriodDays).Scan(&count)
|
|
} else {
|
|
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, lotName, req.PeriodDays).Scan(&count)
|
|
}
|
|
quoteCountPeriod += count
|
|
}
|
|
} else {
|
|
// If no period specified, period count equals total count
|
|
quoteCountPeriod = quoteCountTotal
|
|
}
|
|
|
|
// Get last received price (from the main lot only)
|
|
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 (method, period, coefficient)
|
|
method := req.Method
|
|
if method == "" {
|
|
method = "median"
|
|
}
|
|
|
|
var prices []float64
|
|
if req.PeriodDays > 0 {
|
|
for _, lotName := range lotNames {
|
|
var lotPrices []float64
|
|
if strings.HasSuffix(lotName, "*") {
|
|
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
|
pattern, req.PeriodDays).Pluck("price", &lotPrices)
|
|
} else {
|
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
|
lotName, req.PeriodDays).Pluck("price", &lotPrices)
|
|
}
|
|
prices = append(prices, lotPrices...)
|
|
}
|
|
// Fall back to all time if no prices in period
|
|
if len(prices) == 0 {
|
|
prices = allPrices
|
|
}
|
|
} else {
|
|
prices = allPrices
|
|
}
|
|
|
|
var newPrice *float64
|
|
if len(prices) > 0 {
|
|
sortFloat64s(prices)
|
|
var basePrice float64
|
|
if method == "average" {
|
|
basePrice = calculateAverage(prices)
|
|
} else {
|
|
basePrice = calculateMedian(prices)
|
|
}
|
|
|
|
if req.Coefficient != 0 {
|
|
basePrice = basePrice * (1 + req.Coefficient/100)
|
|
}
|
|
newPrice = &basePrice
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"lot_name": req.LotName,
|
|
"current_price": comp.CurrentPrice,
|
|
"median_all_time": medianAllTime,
|
|
"new_price": newPrice,
|
|
"quote_count_total": quoteCountTotal,
|
|
"quote_count_period": quoteCountPeriod,
|
|
"manual_price": comp.ManualPrice,
|
|
"last_price": lastPrice.Price,
|
|
"last_price_date": lastPrice.Date,
|
|
})
|
|
}
|
|
|
|
// sortFloat64s sorts a slice of float64 in ascending order
|
|
func sortFloat64s(data []float64) {
|
|
sort.Float64s(data)
|
|
}
|
|
|
|
// expandMetaPricesWithCache expands meta_prices using pre-loaded lot names (no DB queries)
|
|
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
|
|
sources := strings.Split(metaPrices, ",")
|
|
var result []string
|
|
seen := make(map[string]bool)
|
|
|
|
for _, source := range sources {
|
|
source = strings.TrimSpace(source)
|
|
if source == "" || source == excludeLot {
|
|
continue
|
|
}
|
|
|
|
if strings.HasSuffix(source, "*") {
|
|
// Wildcard pattern - find matching lots from cache
|
|
prefix := strings.TrimSuffix(source, "*")
|
|
for _, lot := range allLotNames {
|
|
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
|
|
result = append(result, lot)
|
|
seen[lot] = true
|
|
}
|
|
}
|
|
} else if !seen[source] {
|
|
result = append(result, source)
|
|
seen[source] = true
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|