1112 lines
31 KiB
Go
1112 lines
31 KiB
Go
package handlers
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
|
"github.com/gin-gonic/gin"
|
|
"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
|
|
stockImportService *services.StockImportService
|
|
dbUsername string
|
|
}
|
|
|
|
func NewPricingHandler(
|
|
db *gorm.DB,
|
|
pricingService *pricing.Service,
|
|
alertService *alerts.Service,
|
|
componentRepo *repository.ComponentRepository,
|
|
priceRepo *repository.PriceRepository,
|
|
statsRepo *repository.StatsRepository,
|
|
stockImportService *services.StockImportService,
|
|
dbUsername string,
|
|
) *PricingHandler {
|
|
return &PricingHandler{
|
|
db: db,
|
|
pricingService: pricingService,
|
|
alertService: alertService,
|
|
componentRepo: componentRepo,
|
|
priceRepo: priceRepo,
|
|
statsRepo: statsRepo,
|
|
stockImportService: stockImportService,
|
|
dbUsername: dbUsername,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (h *PricingHandler) ImportStockLog(c *gin.Context) {
|
|
if h.stockImportService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Импорт склада доступен только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
fileHeader, err := c.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
|
return
|
|
}
|
|
|
|
file, err := fileHeader.Open()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
content, err := io.ReadAll(file)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read uploaded file"})
|
|
return
|
|
}
|
|
modTime := time.Now()
|
|
if statter, ok := file.(interface{ Stat() (os.FileInfo, error) }); ok {
|
|
if st, statErr := statter.Stat(); statErr == nil {
|
|
modTime = st.ModTime()
|
|
}
|
|
}
|
|
|
|
flusher, ok := c.Writer.(http.Flusher)
|
|
if !ok {
|
|
result, impErr := h.stockImportService.Import(fileHeader.Filename, content, modTime, h.dbUsername, nil)
|
|
if impErr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": impErr.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "completed",
|
|
"rows_total": result.RowsTotal,
|
|
"valid_rows": result.ValidRows,
|
|
"inserted": result.Inserted,
|
|
"deleted": result.Deleted,
|
|
"unmapped": result.Unmapped,
|
|
"conflicts": result.Conflicts,
|
|
"fallback_matches": result.FallbackMatches,
|
|
"parse_errors": result.ParseErrors,
|
|
"import_date": result.ImportDate.Format("2006-01-02"),
|
|
"warehouse_pricelist_id": result.WarehousePLID,
|
|
"warehouse_pricelist_version": result.WarehousePLVer,
|
|
})
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "text/event-stream")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
c.Header("X-Accel-Buffering", "no")
|
|
|
|
send := func(p gin.H) {
|
|
c.SSEvent("progress", p)
|
|
flusher.Flush()
|
|
}
|
|
|
|
send(gin.H{"status": "starting", "message": "Запуск импорта"})
|
|
_, impErr := h.stockImportService.Import(fileHeader.Filename, content, modTime, h.dbUsername, func(p services.StockImportProgress) {
|
|
send(gin.H{
|
|
"status": p.Status,
|
|
"message": p.Message,
|
|
"current": p.Current,
|
|
"total": p.Total,
|
|
"rows_total": p.RowsTotal,
|
|
"valid_rows": p.ValidRows,
|
|
"inserted": p.Inserted,
|
|
"deleted": p.Deleted,
|
|
"unmapped": p.Unmapped,
|
|
"conflicts": p.Conflicts,
|
|
"fallback_matches": p.FallbackMatches,
|
|
"parse_errors": p.ParseErrors,
|
|
"import_date": p.ImportDate,
|
|
"warehouse_pricelist_id": p.PricelistID,
|
|
"warehouse_pricelist_version": p.PricelistVer,
|
|
})
|
|
})
|
|
if impErr != nil {
|
|
send(gin.H{"status": "error", "message": impErr.Error()})
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *PricingHandler) ListStockMappings(c *gin.Context) {
|
|
if h.stockImportService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Сопоставления доступны только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
|
search := c.Query("search")
|
|
|
|
rows, total, err := h.stockImportService.ListMappings(page, perPage, search)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"items": rows,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": perPage,
|
|
})
|
|
}
|
|
|
|
func (h *PricingHandler) UpsertStockMapping(c *gin.Context) {
|
|
if h.stockImportService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Сопоставления доступны только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Partnumber string `json:"partnumber" binding:"required"`
|
|
LotName string `json:"lot_name" binding:"required"`
|
|
Description string `json:"description"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.stockImportService.UpsertMapping(req.Partnumber, req.LotName, req.Description); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "mapping saved"})
|
|
}
|
|
|
|
func (h *PricingHandler) DeleteStockMapping(c *gin.Context) {
|
|
if h.stockImportService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Сопоставления доступны только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
partnumber := c.Param("partnumber")
|
|
deleted, err := h.stockImportService.DeleteMapping(partnumber)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
|
|
}
|