Add hide component feature, usage indicators, and Docker support

- Add is_hidden field to hide components from configurator
- Add colored dot indicator showing component usage status:
  - Green: available in configurator
  - Cyan: used as source for meta-articles
  - Gray: hidden from configurator
- Optimize price recalculation with caching and skip unchanged
- Show current lot name during price recalculation
- Add Dockerfile (Alpine-based multi-stage build)
- Add docker-compose.yml and .dockerignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 22:49:11 +03:00
parent 48921c699d
commit 8309a5dc0e
9 changed files with 563 additions and 191 deletions

View File

@@ -22,9 +22,10 @@ func (h *ComponentHandler) List(c *gin.Context) {
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
filter := repository.ComponentFilter{
Category: c.Query("category"),
Search: c.Query("search"),
HasPrice: c.Query("has_price") == "true",
Category: c.Query("category"),
Search: c.Query("search"),
HasPrice: c.Query("has_price") == "true",
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
}
result, err := h.componentService.List(filter, page, perPage)

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -80,7 +81,8 @@ func (h *PricingHandler) GetStats(c *gin.Context) {
type ComponentWithCount struct {
models.LotMetadata
QuoteCount int64 `json:"quote_count"`
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) {
@@ -116,12 +118,16 @@ func (h *PricingHandler) ListComponents(c *gin.Context) {
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],
}
}
@@ -133,6 +139,79 @@ func (h *PricingHandler) ListComponents(c *gin.Context) {
})
}
// 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) {
lotName := c.Param("lot_name")
@@ -165,6 +244,7 @@ type UpdatePriceRequest struct {
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) {
@@ -189,6 +269,16 @@ func (h *PricingHandler) UpdatePrice(c *gin.Context) {
// 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
@@ -240,18 +330,47 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
method = models.PriceMethodMedian
}
// Get prices based on period
var prices []float64
if periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
lotName, periodDays).Pluck("price", &prices)
// Determine which lot names to use for price calculation
lotNames := []string{lotName}
if comp.MetaPrices != "" {
lotNames = h.expandMetaPrices(comp.MetaPrices, lotName)
}
// If no prices in period, try all time
if len(prices) == 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices)
// 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...)
}
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices)
}
if len(prices) == 0 {
@@ -259,6 +378,7 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
}
// Calculate price based on method
sortFloat64s(prices)
var finalPrice float64
switch method {
case models.PriceMethodMedian:
@@ -299,61 +419,95 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
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()
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "processing", "updated": 0, "skipped": 0, "manual": 0, "errors": 0})
c.Writer.Flush()
// Process components individually to respect their settings
var updated, skipped, manual, errors int
var updated, skipped, manual, unchanged, errors int
now := time.Now()
progressCounter := 0
for i, comp := range components {
// If manual price is set, use it
for _, comp := range components {
progressCounter++
// If manual price is set, skip recalculation
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
err := h.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", comp.LotName).
Updates(map[string]interface{}{
"current_price": *comp.ManualPrice,
"price_updated_at": now,
}).Error
if err != nil {
errors++
} else {
manual++
}
manual++
goto sendProgress
}
// Calculate price based on component's individual settings
{
var basePrice *float64
periodDays := comp.PricePeriodDays
method := comp.PriceMethod
if method == "" {
method = models.PriceMethodMedian
}
// Build query based on period
var query string
var args []interface{}
if periodDays > 0 {
query = `SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`
args = []interface{}{comp.LotName, periodDays}
// Determine source lots for price calculation (using cached lot names)
var sourceLots []string
if comp.MetaPrices != "" {
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
} else {
query = `SELECT price FROM lot_log WHERE lot = ? ORDER BY price`
args = []interface{}{comp.LotName}
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
h.db.Raw(query, args...).Pluck("price", &prices)
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 = ? ORDER BY price`, comp.LotName).Pluck("price", &prices)
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices)
}
if len(prices) == 0 {
@@ -362,24 +516,22 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
}
// Calculate price based on method
var basePrice float64
switch method {
case models.PriceMethodMedian:
median := calculateMedian(prices)
basePrice = &median
basePrice = calculateMedian(prices)
case models.PriceMethodAverage:
avg := calculateAverage(prices)
basePrice = &avg
basePrice = calculateAverage(prices)
default:
median := calculateMedian(prices)
basePrice = &median
basePrice = calculateMedian(prices)
}
if basePrice == nil || *basePrice <= 0 {
if basePrice <= 0 {
skipped++
goto sendProgress
}
finalPrice := *basePrice
finalPrice := basePrice
// Apply coefficient
if comp.PriceCoefficient != 0 {
@@ -401,16 +553,18 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
}
sendProgress:
// Send progress update every 50 components
if (i+1)%50 == 0 || i == len(components)-1 {
// 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 + errors,
"total": total,
"updated": updated,
"skipped": skipped,
"manual": manual,
"errors": errors,
"status": "processing",
"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()
}
@@ -421,13 +575,14 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
// Send completion
c.SSEvent("progress", gin.H{
"current": updated + skipped + manual + errors,
"total": total,
"updated": updated,
"skipped": skipped,
"manual": manual,
"errors": errors,
"status": "completed",
"current": updated + skipped + manual + unchanged + errors,
"total": total,
"updated": updated,
"skipped": skipped,
"manual": manual,
"unchanged": unchanged,
"errors": errors,
"status": "completed",
})
c.Writer.Flush()
}
@@ -507,6 +662,8 @@ type PreviewPriceRequest struct {
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) {
@@ -523,22 +680,48 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
return
}
// Get all prices for calculations
// 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
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, req.LotName).Pluck("price", &allPrices)
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
// Get quote count (from all relevant lots)
var quoteCount int64
h.db.Model(&models.LotLog{}).Where("lot = ?", req.LotName).Count(&quoteCount)
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)
}
quoteCount += count
}
// Get last received price
// Get last received price (from the main lot only)
var lastPrice struct {
Price *float64
Date *time.Time
@@ -553,8 +736,18 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
var prices []float64
if req.PeriodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
req.LotName, req.PeriodDays).Pluck("price", &prices)
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
@@ -565,6 +758,7 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
var newPrice *float64
if len(prices) > 0 {
sortFloat64s(prices)
var basePrice float64
if method == "average" {
basePrice = calculateAverage(prices)
@@ -589,3 +783,38 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
"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
}

View File

@@ -51,6 +51,7 @@ type LotMetadata struct {
MetaPrices string `gorm:"size:1000" json:"meta_prices"`
MetaMethod string `gorm:"size:20" json:"meta_method"`
MetaPeriodDays int `gorm:"default:90" json:"meta_period_days"`
IsHidden bool `gorm:"default:false" json:"is_hidden"`
// Relations
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`

View File

@@ -16,11 +16,12 @@ func NewComponentRepository(db *gorm.DB) *ComponentRepository {
}
type ComponentFilter struct {
Category string
Search string
HasPrice bool
SortField string
SortDir string
Category string
Search string
HasPrice bool
ExcludeHidden bool
SortField string
SortDir string
}
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
@@ -42,6 +43,9 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
if filter.HasPrice {
query = query.Where("current_price IS NOT NULL AND current_price > 0")
}
if filter.ExcludeHidden {
query = query.Where("is_hidden = ? OR is_hidden IS NULL", false)
}
query.Count(&total)