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:
33
.dockerignore
Normal file
33
.dockerignore
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
server
|
||||||
|
*.exe
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# Config with secrets
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
.claude
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
*_test.go
|
||||||
|
test_*.csv
|
||||||
|
test_*.xlsx
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates tzdata
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go mod files first for better caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||||
|
-ldflags="-s -w" \
|
||||||
|
-o /app/quoteforge \
|
||||||
|
./cmd/server
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN adduser -D -g '' appuser
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /app/quoteforge .
|
||||||
|
|
||||||
|
# Copy web templates and static files
|
||||||
|
COPY --from=builder /app/web ./web
|
||||||
|
|
||||||
|
# Copy migrations
|
||||||
|
COPY --from=builder /app/migrations ./migrations
|
||||||
|
|
||||||
|
# Copy example config (actual config should be mounted)
|
||||||
|
COPY --from=builder /app/config.example.yaml ./config.example.yaml
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/quoteforge"]
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
quoteforge:
|
||||||
|
build: .
|
||||||
|
container_name: quoteforge
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/app/config.yaml:ro
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Moscow
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
@@ -25,6 +25,7 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
Category: c.Query("category"),
|
Category: c.Query("category"),
|
||||||
Search: c.Query("search"),
|
Search: c.Query("search"),
|
||||||
HasPrice: c.Query("has_price") == "true",
|
HasPrice: c.Query("has_price") == "true",
|
||||||
|
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.componentService.List(filter, page, perPage)
|
result, err := h.componentService.List(filter, page, perPage)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -81,6 +82,7 @@ func (h *PricingHandler) GetStats(c *gin.Context) {
|
|||||||
type ComponentWithCount struct {
|
type ComponentWithCount struct {
|
||||||
models.LotMetadata
|
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) {
|
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
||||||
@@ -116,12 +118,16 @@ func (h *PricingHandler) ListComponents(c *gin.Context) {
|
|||||||
|
|
||||||
counts, _ := h.priceRepo.GetQuoteCounts(lotNames)
|
counts, _ := h.priceRepo.GetQuoteCounts(lotNames)
|
||||||
|
|
||||||
|
// Get meta usage information
|
||||||
|
metaUsage := h.getMetaUsageMap(lotNames)
|
||||||
|
|
||||||
// Combine components with counts
|
// Combine components with counts
|
||||||
result := make([]ComponentWithCount, len(components))
|
result := make([]ComponentWithCount, len(components))
|
||||||
for i, comp := range components {
|
for i, comp := range components {
|
||||||
result[i] = ComponentWithCount{
|
result[i] = ComponentWithCount{
|
||||||
LotMetadata: comp,
|
LotMetadata: comp,
|
||||||
QuoteCount: counts[comp.LotName],
|
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) {
|
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
||||||
lotName := c.Param("lot_name")
|
lotName := c.Param("lot_name")
|
||||||
|
|
||||||
@@ -165,6 +244,7 @@ type UpdatePriceRequest struct {
|
|||||||
MetaPrices string `json:"meta_prices"`
|
MetaPrices string `json:"meta_prices"`
|
||||||
MetaMethod string `json:"meta_method"`
|
MetaMethod string `json:"meta_method"`
|
||||||
MetaPeriod int `json:"meta_period"`
|
MetaPeriod int `json:"meta_period"`
|
||||||
|
IsHidden bool `json:"is_hidden"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
||||||
@@ -189,6 +269,16 @@ func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
|||||||
// Update coefficient
|
// Update coefficient
|
||||||
updates["price_coefficient"] = req.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
|
// Handle manual price
|
||||||
if req.ClearManual {
|
if req.ClearManual {
|
||||||
updates["manual_price"] = nil
|
updates["manual_price"] = nil
|
||||||
@@ -240,18 +330,47 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
|||||||
method = models.PriceMethodMedian
|
method = models.PriceMethodMedian
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get prices based on period
|
// Determine which lot names to use for price calculation
|
||||||
var prices []float64
|
lotNames := []string{lotName}
|
||||||
if periodDays > 0 {
|
if comp.MetaPrices != "" {
|
||||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
lotNames = h.expandMetaPrices(comp.MetaPrices, lotName)
|
||||||
lotName, periodDays).Pluck("price", &prices)
|
}
|
||||||
|
|
||||||
// If no prices in period, try all time
|
// Get prices based on period from all relevant lots
|
||||||
if len(prices) == 0 {
|
var prices []float64
|
||||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices)
|
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 {
|
} else {
|
||||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices)
|
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 {
|
if len(prices) == 0 {
|
||||||
@@ -259,6 +378,7 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate price based on method
|
// Calculate price based on method
|
||||||
|
sortFloat64s(prices)
|
||||||
var finalPrice float64
|
var finalPrice float64
|
||||||
switch method {
|
switch method {
|
||||||
case models.PriceMethodMedian:
|
case models.PriceMethodMedian:
|
||||||
@@ -299,61 +419,95 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
|||||||
h.db.Find(&components)
|
h.db.Find(&components)
|
||||||
total := int64(len(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
|
// Send initial progress
|
||||||
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"})
|
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"})
|
||||||
c.Writer.Flush()
|
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
|
// Process components individually to respect their settings
|
||||||
var updated, skipped, manual, errors int
|
var updated, skipped, manual, unchanged, errors int
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
progressCounter := 0
|
||||||
|
|
||||||
for i, comp := range components {
|
for _, comp := range components {
|
||||||
// If manual price is set, use it
|
progressCounter++
|
||||||
|
|
||||||
|
// If manual price is set, skip recalculation
|
||||||
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
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
|
goto sendProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate price based on component's individual settings
|
// Calculate price based on component's individual settings
|
||||||
{
|
{
|
||||||
var basePrice *float64
|
|
||||||
periodDays := comp.PricePeriodDays
|
periodDays := comp.PricePeriodDays
|
||||||
method := comp.PriceMethod
|
method := comp.PriceMethod
|
||||||
if method == "" {
|
if method == "" {
|
||||||
method = models.PriceMethodMedian
|
method = models.PriceMethodMedian
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build query based on period
|
// Determine source lots for price calculation (using cached lot names)
|
||||||
var query string
|
var sourceLots []string
|
||||||
var args []interface{}
|
if comp.MetaPrices != "" {
|
||||||
|
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
|
||||||
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}
|
|
||||||
} else {
|
} else {
|
||||||
query = `SELECT price FROM lot_log WHERE lot = ? ORDER BY price`
|
sourceLots = []string{comp.LotName}
|
||||||
args = []interface{}{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
|
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 no prices in period, try all time
|
||||||
if len(prices) == 0 && periodDays > 0 {
|
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 {
|
if len(prices) == 0 {
|
||||||
@@ -362,24 +516,22 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate price based on method
|
// Calculate price based on method
|
||||||
|
var basePrice float64
|
||||||
switch method {
|
switch method {
|
||||||
case models.PriceMethodMedian:
|
case models.PriceMethodMedian:
|
||||||
median := calculateMedian(prices)
|
basePrice = calculateMedian(prices)
|
||||||
basePrice = &median
|
|
||||||
case models.PriceMethodAverage:
|
case models.PriceMethodAverage:
|
||||||
avg := calculateAverage(prices)
|
basePrice = calculateAverage(prices)
|
||||||
basePrice = &avg
|
|
||||||
default:
|
default:
|
||||||
median := calculateMedian(prices)
|
basePrice = calculateMedian(prices)
|
||||||
basePrice = &median
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if basePrice == nil || *basePrice <= 0 {
|
if basePrice <= 0 {
|
||||||
skipped++
|
skipped++
|
||||||
goto sendProgress
|
goto sendProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
finalPrice := *basePrice
|
finalPrice := basePrice
|
||||||
|
|
||||||
// Apply coefficient
|
// Apply coefficient
|
||||||
if comp.PriceCoefficient != 0 {
|
if comp.PriceCoefficient != 0 {
|
||||||
@@ -401,16 +553,18 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendProgress:
|
sendProgress:
|
||||||
// Send progress update every 50 components
|
// Send progress update every 10 components to reduce overhead
|
||||||
if (i+1)%50 == 0 || i == len(components)-1 {
|
if progressCounter%10 == 0 || progressCounter == int(total) {
|
||||||
c.SSEvent("progress", gin.H{
|
c.SSEvent("progress", gin.H{
|
||||||
"current": updated + skipped + manual + errors,
|
"current": updated + skipped + manual + unchanged + errors,
|
||||||
"total": total,
|
"total": total,
|
||||||
"updated": updated,
|
"updated": updated,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"manual": manual,
|
"manual": manual,
|
||||||
|
"unchanged": unchanged,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
"status": "processing",
|
"status": "processing",
|
||||||
|
"lot_name": comp.LotName,
|
||||||
})
|
})
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
}
|
}
|
||||||
@@ -421,11 +575,12 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
|||||||
|
|
||||||
// Send completion
|
// Send completion
|
||||||
c.SSEvent("progress", gin.H{
|
c.SSEvent("progress", gin.H{
|
||||||
"current": updated + skipped + manual + errors,
|
"current": updated + skipped + manual + unchanged + errors,
|
||||||
"total": total,
|
"total": total,
|
||||||
"updated": updated,
|
"updated": updated,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"manual": manual,
|
"manual": manual,
|
||||||
|
"unchanged": unchanged,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
})
|
})
|
||||||
@@ -507,6 +662,8 @@ type PreviewPriceRequest struct {
|
|||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
PeriodDays int `json:"period_days"`
|
PeriodDays int `json:"period_days"`
|
||||||
Coefficient float64 `json:"coefficient"`
|
Coefficient float64 `json:"coefficient"`
|
||||||
|
MetaEnabled bool `json:"meta_enabled"`
|
||||||
|
MetaPrices string `json:"meta_prices"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||||
@@ -523,22 +680,48 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
|||||||
return
|
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
|
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
|
// Calculate median for all time
|
||||||
var medianAllTime *float64
|
var medianAllTime *float64
|
||||||
if len(allPrices) > 0 {
|
if len(allPrices) > 0 {
|
||||||
|
sortFloat64s(allPrices)
|
||||||
median := calculateMedian(allPrices)
|
median := calculateMedian(allPrices)
|
||||||
medianAllTime = &median
|
medianAllTime = &median
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get quote count
|
// Get quote count (from all relevant lots)
|
||||||
var quoteCount int64
|
var quoteCount int64
|
||||||
h.db.Model(&models.LotLog{}).Where("lot = ?", req.LotName).Count("eCount)
|
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 {
|
var lastPrice struct {
|
||||||
Price *float64
|
Price *float64
|
||||||
Date *time.Time
|
Date *time.Time
|
||||||
@@ -553,8 +736,18 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
|||||||
|
|
||||||
var prices []float64
|
var prices []float64
|
||||||
if req.PeriodDays > 0 {
|
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`,
|
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)
|
lotName, req.PeriodDays).Pluck("price", &lotPrices)
|
||||||
|
}
|
||||||
|
prices = append(prices, lotPrices...)
|
||||||
|
}
|
||||||
// Fall back to all time if no prices in period
|
// Fall back to all time if no prices in period
|
||||||
if len(prices) == 0 {
|
if len(prices) == 0 {
|
||||||
prices = allPrices
|
prices = allPrices
|
||||||
@@ -565,6 +758,7 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
|||||||
|
|
||||||
var newPrice *float64
|
var newPrice *float64
|
||||||
if len(prices) > 0 {
|
if len(prices) > 0 {
|
||||||
|
sortFloat64s(prices)
|
||||||
var basePrice float64
|
var basePrice float64
|
||||||
if method == "average" {
|
if method == "average" {
|
||||||
basePrice = calculateAverage(prices)
|
basePrice = calculateAverage(prices)
|
||||||
@@ -589,3 +783,38 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
|||||||
"last_price_date": lastPrice.Date,
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type LotMetadata struct {
|
|||||||
MetaPrices string `gorm:"size:1000" json:"meta_prices"`
|
MetaPrices string `gorm:"size:1000" json:"meta_prices"`
|
||||||
MetaMethod string `gorm:"size:20" json:"meta_method"`
|
MetaMethod string `gorm:"size:20" json:"meta_method"`
|
||||||
MetaPeriodDays int `gorm:"default:90" json:"meta_period_days"`
|
MetaPeriodDays int `gorm:"default:90" json:"meta_period_days"`
|
||||||
|
IsHidden bool `gorm:"default:false" json:"is_hidden"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`
|
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type ComponentFilter struct {
|
|||||||
Category string
|
Category string
|
||||||
Search string
|
Search string
|
||||||
HasPrice bool
|
HasPrice bool
|
||||||
|
ExcludeHidden bool
|
||||||
SortField string
|
SortField string
|
||||||
SortDir string
|
SortDir string
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,9 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
|
|||||||
if filter.HasPrice {
|
if filter.HasPrice {
|
||||||
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
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)
|
query.Count(&total)
|
||||||
|
|
||||||
|
|||||||
2
migrations/003_add_is_hidden.sql
Normal file
2
migrations/003_add_is_hidden.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add is_hidden column to qt_lot_metadata table
|
||||||
|
ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator';
|
||||||
@@ -81,37 +81,25 @@
|
|||||||
<input type="checkbox" id="modal-meta-enabled" class="mr-2" onchange="toggleMetaPrice()">
|
<input type="checkbox" id="modal-meta-enabled" class="mr-2" onchange="toggleMetaPrice()">
|
||||||
<span class="text-sm font-medium text-gray-700">Мета артикул</span>
|
<span class="text-sm font-medium text-gray-700">Мета артикул</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="meta-price-fields" class="hidden grid grid-cols-2 gap-4 mt-2">
|
<div id="meta-price-fields" class="hidden mt-2">
|
||||||
<div>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Источники цен</label>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Показатели цен</label>
|
|
||||||
<input type="text" id="modal-meta-prices" class="w-full px-3 py-2 border rounded" placeholder="Артикулы через запятую (например: CPU_AMD_9654, MB_INTEL_4.Sapphire_2S)">
|
<input type="text" id="modal-meta-prices" class="w-full px-3 py-2 border rounded" placeholder="Артикулы через запятую (например: CPU_AMD_9654, MB_INTEL_4.Sapphire_2S)">
|
||||||
<p class="text-xs text-gray-500 mt-1">Артикулы, чьи цены будут использоваться в расчете. <br>Для автоматического подбора используйте * в конце названия (например: CPU_AMD_9654*)</p>
|
<p class="text-xs text-gray-500 mt-1">Артикулы, чьи цены будут использоваться в расчете. Для автоматического подбора используйте * в конце (например: CPU_AMD_9654*)</p>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчета</label>
|
|
||||||
<select id="modal-meta-method" class="w-full px-3 py-2 border rounded">
|
|
||||||
<option value="median">Медиана</option>
|
|
||||||
<option value="average">Среднее</option>
|
|
||||||
<option value="weighted_median">Взвешенная медиана</option>
|
|
||||||
</select>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1 mt-2">Период расчета</label>
|
|
||||||
<select id="modal-meta-period" class="w-full px-3 py-2 border rounded">
|
|
||||||
<option value="7">1 неделя</option>
|
|
||||||
<option value="30">1 месяц</option>
|
|
||||||
<option value="90" selected>1 квартал</option>
|
|
||||||
<option value="365">1 год</option>
|
|
||||||
<option value="0">Всё время</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчёта</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчёта</label>
|
||||||
<select id="modal-method" class="w-full px-3 py-2 border rounded">
|
<select id="modal-method" class="w-full px-3 py-2 border rounded" onchange="onMethodChange()">
|
||||||
<option value="median">Медиана</option>
|
<option value="median">Медиана</option>
|
||||||
<option value="average">Среднее</option>
|
<option value="average">Среднее</option>
|
||||||
|
<option value="manual">Установить цену вручную</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="manual-price-field" class="hidden">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Ручная цена (USD)</label>
|
||||||
|
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Период расчёта</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Период расчёта</label>
|
||||||
@@ -130,42 +118,9 @@
|
|||||||
<p class="text-xs text-gray-500 mt-1">Например: 30 для +30%, -10 для -10%</p>
|
<p class="text-xs text-gray-500 mt-1">Например: 30 для +30%, -10 для -10%</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t pt-4">
|
<div class="flex items-center pt-2 border-t">
|
||||||
<label class="flex items-center mb-2">
|
<input type="checkbox" id="modal-hidden" class="mr-2">
|
||||||
<input type="checkbox" id="modal-meta-enabled" class="mr-2" onchange="toggleMetaPrice()">
|
<span class="text-sm font-medium text-gray-700">Скрыть из конфигуратора</span>
|
||||||
<span class="text-sm font-medium text-gray-700">Мета артикул</span>
|
|
||||||
</label>
|
|
||||||
<div id="meta-price-fields" class="hidden grid grid-cols-2 gap-4 mt-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Показатели цен</label>
|
|
||||||
<input type="text" id="modal-meta-prices" class="w-full px-3 py-2 border rounded" placeholder="Артикулы через запятую (например: CPU_AMD_9654, MB_INTEL_4.Sapphire_2S)">
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Артикулы, чьи цены будут использоваться в расчете. <br>Для автоматического подбора используйте * в конце названия (например: CPU_AMD_9654*)</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчета</label>
|
|
||||||
<select id="modal-meta-method" class="w-full px-3 py-2 border rounded">
|
|
||||||
<option value="median">Медиана</option>
|
|
||||||
<option value="average">Среднее</option>
|
|
||||||
<option value="weighted_median">Взвешенная медиана</option>
|
|
||||||
</select>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1 mt-2">Период расчета</label>
|
|
||||||
<select id="modal-meta-period" class="w-full px-3 py-2 border rounded">
|
|
||||||
<option value="7">1 неделя</option>
|
|
||||||
<option value="30">1 месяц</option>
|
|
||||||
<option value="90" selected>1 квартал</option>
|
|
||||||
<option value="365">1 год</option>
|
|
||||||
<option value="0">Всё время</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3">
|
|
||||||
<label class="flex items-center mb-2">
|
|
||||||
<input type="checkbox" id="modal-manual-enabled" class="mr-2" onchange="toggleManualPrice()">
|
|
||||||
<span class="text-sm font-medium text-gray-700">Установить цену вручную</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD" disabled>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-gray-50 p-3 rounded space-y-2">
|
<div class="bg-gray-50 p-3 rounded space-y-2">
|
||||||
@@ -360,11 +315,47 @@ function renderComponents(components, total) {
|
|||||||
const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : '—';
|
const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : '—';
|
||||||
const quoteCount = c.quote_count || 0;
|
const quoteCount = c.quote_count || 0;
|
||||||
const popularity = c.popularity_score ? c.popularity_score.toFixed(2) : '0.00';
|
const popularity = c.popularity_score ? c.popularity_score.toFixed(2) : '0.00';
|
||||||
|
const isHidden = c.is_hidden || quoteCount === 0;
|
||||||
|
const usedInMeta = c.used_in_meta && c.used_in_meta.length > 0;
|
||||||
|
|
||||||
|
// Determine status indicator (colored dot)
|
||||||
|
let dotColor, dotTitle;
|
||||||
|
if (usedInMeta) {
|
||||||
|
// Used as source for meta-articles - cyan
|
||||||
|
dotColor = 'bg-cyan-500';
|
||||||
|
dotTitle = 'Используется в мета: ' + c.used_in_meta.join(', ');
|
||||||
|
} else if (!isHidden) {
|
||||||
|
// Available in configurator - green
|
||||||
|
dotColor = 'bg-green-500';
|
||||||
|
dotTitle = 'Доступен в конфигураторе';
|
||||||
|
} else {
|
||||||
|
// Hidden and not used - gray
|
||||||
|
dotColor = 'bg-gray-400';
|
||||||
|
dotTitle = 'Скрыт из конфигуратора';
|
||||||
|
}
|
||||||
|
|
||||||
// Build settings summary
|
// Build settings summary
|
||||||
|
let settingsHtml = '';
|
||||||
|
|
||||||
|
if (isHidden) {
|
||||||
|
settingsHtml = '<span class="text-red-600 font-medium">СКРЫТ</span>';
|
||||||
|
} else {
|
||||||
let settings = [];
|
let settings = [];
|
||||||
const method = c.price_method || 'median';
|
const method = c.price_method || 'median';
|
||||||
settings.push(method === 'median' ? 'М' : 'С');
|
const hasManualPrice = c.manual_price && c.manual_price > 0;
|
||||||
|
const hasMeta = c.meta_prices && c.meta_prices.trim() !== '';
|
||||||
|
|
||||||
|
// Method indicator
|
||||||
|
if (hasManualPrice) {
|
||||||
|
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
|
||||||
|
} else if (method === 'average') {
|
||||||
|
settings.push('Сред');
|
||||||
|
} else {
|
||||||
|
settings.push('Мед');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Period (only if not manual price)
|
||||||
|
if (!hasManualPrice) {
|
||||||
const period = c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90;
|
const period = c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90;
|
||||||
if (period === 7) settings.push('1н');
|
if (period === 7) settings.push('1н');
|
||||||
else if (period === 30) settings.push('1м');
|
else if (period === 30) settings.push('1м');
|
||||||
@@ -372,21 +363,29 @@ function renderComponents(components, total) {
|
|||||||
else if (period === 365) settings.push('1г');
|
else if (period === 365) settings.push('1г');
|
||||||
else if (period === 0) settings.push('все');
|
else if (period === 0) settings.push('все');
|
||||||
else settings.push(period + 'д');
|
else settings.push(period + 'д');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coefficient
|
||||||
if (c.price_coefficient && c.price_coefficient !== 0) {
|
if (c.price_coefficient && c.price_coefficient !== 0) {
|
||||||
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
|
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
|
||||||
}
|
}
|
||||||
if (c.manual_price && c.manual_price > 0) {
|
|
||||||
settings.push('РУЧН');
|
// Meta article indicator
|
||||||
|
if (hasMeta) {
|
||||||
|
settings.push('<span class="text-purple-600 font-medium">МЕТА</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsHtml = settings.join(' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + idx + ')">';
|
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + idx + ')">';
|
||||||
html += '<td class="px-3 py-2 text-sm font-mono">' + escapeHtml(c.lot_name) + '</td>';
|
html += '<td class="px-3 py-2 text-sm font-mono"><span class="inline-flex items-center gap-2"><span class="w-2.5 h-2.5 rounded-full ' + dotColor + ' flex-shrink-0" title="' + escapeHtml(dotTitle) + '"></span>' + escapeHtml(c.lot_name) + '</span></td>';
|
||||||
html += '<td class="px-3 py-2 text-sm">' + escapeHtml(category) + '</td>';
|
html += '<td class="px-3 py-2 text-sm">' + escapeHtml(category) + '</td>';
|
||||||
html += '<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate">' + escapeHtml(desc) + '</td>';
|
html += '<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate">' + escapeHtml(desc) + '</td>';
|
||||||
html += '<td class="px-3 py-2 text-sm text-right">' + popularity + '</td>';
|
html += '<td class="px-3 py-2 text-sm text-right">' + popularity + '</td>';
|
||||||
html += '<td class="px-3 py-2 text-sm text-right">' + quoteCount + '</td>';
|
html += '<td class="px-3 py-2 text-sm text-right">' + quoteCount + '</td>';
|
||||||
html += '<td class="px-3 py-2 text-sm text-right font-medium">' + price + '</td>';
|
html += '<td class="px-3 py-2 text-sm text-right font-medium">' + price + '</td>';
|
||||||
html += '<td class="px-3 py-2 text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">' + settings.join(' | ') + '</span></td>';
|
html += '<td class="px-3 py-2 text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">' + settingsHtml + '</span></td>';
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -407,14 +406,41 @@ function openModal(idx) {
|
|||||||
if (!c) return;
|
if (!c) return;
|
||||||
|
|
||||||
document.getElementById('modal-lot-name').value = c.lot_name;
|
document.getElementById('modal-lot-name').value = c.lot_name;
|
||||||
document.getElementById('modal-method').value = c.price_method || 'median';
|
|
||||||
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
|
|
||||||
document.getElementById('modal-coefficient').value = c.price_coefficient || 0;
|
document.getElementById('modal-coefficient').value = c.price_coefficient || 0;
|
||||||
|
|
||||||
const hasManual = c.manual_price && c.manual_price > 0;
|
const hasManual = c.manual_price && c.manual_price > 0;
|
||||||
document.getElementById('modal-manual-enabled').checked = hasManual;
|
if (hasManual) {
|
||||||
document.getElementById('modal-manual-price').value = hasManual ? c.manual_price : '';
|
document.getElementById('modal-method').value = 'manual';
|
||||||
document.getElementById('modal-manual-price').disabled = !hasManual;
|
document.getElementById('modal-manual-price').value = c.manual_price;
|
||||||
|
document.getElementById('manual-price-field').classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
document.getElementById('modal-method').value = c.price_method || 'median';
|
||||||
|
document.getElementById('modal-manual-price').value = '';
|
||||||
|
document.getElementById('manual-price-field').classList.add('hidden');
|
||||||
|
}
|
||||||
|
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
|
||||||
|
|
||||||
|
// Load meta prices settings
|
||||||
|
const hasMeta = c.meta_prices && c.meta_prices.trim() !== '';
|
||||||
|
document.getElementById('modal-meta-enabled').checked = hasMeta;
|
||||||
|
document.getElementById('modal-meta-prices').value = c.meta_prices || '';
|
||||||
|
if (hasMeta) {
|
||||||
|
document.getElementById('meta-price-fields').classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
document.getElementById('meta-price-fields').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load hidden flag
|
||||||
|
const quoteCount = c.quote_count || 0;
|
||||||
|
const hiddenCheckbox = document.getElementById('modal-hidden');
|
||||||
|
if (quoteCount === 0) {
|
||||||
|
// Если нет котировок - чекбокс установлен и заблокирован
|
||||||
|
hiddenCheckbox.checked = true;
|
||||||
|
hiddenCheckbox.disabled = true;
|
||||||
|
} else {
|
||||||
|
hiddenCheckbox.checked = c.is_hidden || false;
|
||||||
|
hiddenCheckbox.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Reset price displays while loading
|
// Reset price displays while loading
|
||||||
document.getElementById('modal-last-price').textContent = '...';
|
document.getElementById('modal-last-price').textContent = '...';
|
||||||
@@ -430,6 +456,20 @@ function openModal(idx) {
|
|||||||
fetchPreview();
|
fetchPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onMethodChange() {
|
||||||
|
const method = document.getElementById('modal-method').value;
|
||||||
|
const manualField = document.getElementById('manual-price-field');
|
||||||
|
if (method === 'manual') {
|
||||||
|
manualField.classList.remove('hidden');
|
||||||
|
// При выборе "Установить цену вручную" снимаем галочку "Мета артикул"
|
||||||
|
document.getElementById('modal-meta-enabled').checked = false;
|
||||||
|
document.getElementById('meta-price-fields').classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
manualField.classList.add('hidden');
|
||||||
|
}
|
||||||
|
fetchPreview();
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPreview() {
|
async function fetchPreview() {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -445,8 +485,8 @@ async function fetchPreview() {
|
|||||||
|
|
||||||
if (metaEnabled) {
|
if (metaEnabled) {
|
||||||
metaPrices = document.getElementById('modal-meta-prices').value.trim();
|
metaPrices = document.getElementById('modal-meta-prices').value.trim();
|
||||||
metaMethod = document.getElementById('modal-meta-method').value;
|
metaMethod = method;
|
||||||
metaPeriod = parseInt(document.getElementById('modal-meta-period').value) || 0;
|
metaPeriod = periodDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -520,32 +560,18 @@ function toggleMetaPrice() {
|
|||||||
fields.classList.toggle('hidden', !enabled);
|
fields.classList.toggle('hidden', !enabled);
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// When enabling meta price, disable manual price
|
// When enabling meta price, reset method to median if it was manual
|
||||||
document.getElementById('modal-manual-enabled').checked = false;
|
const method = document.getElementById('modal-method').value;
|
||||||
document.getElementById('modal-manual-price').disabled = true;
|
if (method === 'manual') {
|
||||||
|
document.getElementById('modal-method').value = 'median';
|
||||||
|
document.getElementById('manual-price-field').classList.add('hidden');
|
||||||
document.getElementById('modal-manual-price').value = '';
|
document.getElementById('modal-manual-price').value = '';
|
||||||
|
}
|
||||||
// Auto-fill with wildcard pattern
|
// Auto-fill with wildcard pattern
|
||||||
const lotName = document.getElementById('modal-lot-name').value;
|
const lotName = document.getElementById('modal-lot-name').value;
|
||||||
if (lotName) {
|
if (lotName) {
|
||||||
autoFillMetaPrices(lotName);
|
autoFillMetaPrices(lotName);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// When disabling meta price, reset to default values
|
|
||||||
// Don't change the main settings - they should stay as they were
|
|
||||||
}
|
|
||||||
fetchPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleManualPrice() {
|
|
||||||
const enabled = document.getElementById('modal-manual-enabled').checked;
|
|
||||||
document.getElementById('modal-manual-price').disabled = !enabled;
|
|
||||||
if (!enabled) {
|
|
||||||
document.getElementById('modal-manual-price').value = '';
|
|
||||||
}
|
|
||||||
// When enabling manual price, disable meta price
|
|
||||||
if (enabled) {
|
|
||||||
document.getElementById('modal-meta-enabled').checked = false;
|
|
||||||
document.getElementById('meta-price-fields').classList.add('hidden');
|
|
||||||
}
|
}
|
||||||
fetchPreview();
|
fetchPreview();
|
||||||
}
|
}
|
||||||
@@ -569,9 +595,12 @@ async function savePrice() {
|
|||||||
const periodDaysStr = document.getElementById('modal-period').value;
|
const periodDaysStr = document.getElementById('modal-period').value;
|
||||||
const periodDays = periodDaysStr !== '' ? parseInt(periodDaysStr) : 90;
|
const periodDays = periodDaysStr !== '' ? parseInt(periodDaysStr) : 90;
|
||||||
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
|
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
|
||||||
const manualEnabled = document.getElementById('modal-manual-enabled').checked;
|
const manualEnabled = method === 'manual';
|
||||||
const manualPrice = manualEnabled ? parseFloat(document.getElementById('modal-manual-price').value) : null;
|
const manualPrice = manualEnabled ? parseFloat(document.getElementById('modal-manual-price').value) : null;
|
||||||
const metaEnabled = document.getElementById('modal-meta-enabled').checked;
|
const metaEnabled = document.getElementById('modal-meta-enabled').checked;
|
||||||
|
const hiddenCheckbox = document.getElementById('modal-hidden');
|
||||||
|
// Если чекбокс заблокирован (нет котировок), всегда true
|
||||||
|
const isHidden = hiddenCheckbox.disabled ? true : hiddenCheckbox.checked;
|
||||||
|
|
||||||
let metaPrices = '';
|
let metaPrices = '';
|
||||||
let metaMethod = '';
|
let metaMethod = '';
|
||||||
@@ -579,20 +608,21 @@ async function savePrice() {
|
|||||||
|
|
||||||
if (metaEnabled) {
|
if (metaEnabled) {
|
||||||
metaPrices = document.getElementById('modal-meta-prices').value.trim();
|
metaPrices = document.getElementById('modal-meta-prices').value.trim();
|
||||||
metaMethod = document.getElementById('modal-meta-method').value;
|
metaMethod = manualEnabled ? 'median' : method;
|
||||||
metaPeriod = parseInt(document.getElementById('modal-meta-period').value) || 0;
|
metaPeriod = periodDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
lot_name: lotName,
|
lot_name: lotName,
|
||||||
method: method,
|
method: manualEnabled ? 'median' : method,
|
||||||
period_days: periodDays,
|
period_days: periodDays,
|
||||||
coefficient: coefficient,
|
coefficient: coefficient,
|
||||||
clear_manual: !manualEnabled,
|
clear_manual: !manualEnabled,
|
||||||
meta_enabled: metaEnabled,
|
meta_enabled: metaEnabled,
|
||||||
meta_prices: metaPrices,
|
meta_prices: metaPrices,
|
||||||
meta_method: metaMethod,
|
meta_method: metaMethod,
|
||||||
meta_period: metaPeriod
|
meta_period: metaPeriod,
|
||||||
|
is_hidden: isHidden
|
||||||
};
|
};
|
||||||
|
|
||||||
if (manualEnabled && manualPrice > 0) {
|
if (manualEnabled && manualPrice > 0) {
|
||||||
@@ -716,10 +746,10 @@ function recalculateAll() {
|
|||||||
progressText.textContent = 'Пересчёт завершён!';
|
progressText.textContent = 'Пересчёт завершён!';
|
||||||
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
||||||
} else {
|
} else {
|
||||||
progressText.textContent = 'Обработка компонентов...';
|
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
|
||||||
}
|
}
|
||||||
|
|
||||||
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
|
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Без изменений: ' + (data.unchanged || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.log('Parse error:', e, line);
|
console.log('Parse error:', e, line);
|
||||||
}
|
}
|
||||||
@@ -815,9 +845,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadTab('alerts');
|
loadTab('alerts');
|
||||||
|
|
||||||
// Add event listeners for preview updates
|
// Add event listeners for preview updates
|
||||||
document.getElementById('modal-method').addEventListener('change', fetchPreview);
|
|
||||||
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
||||||
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
|
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
|
||||||
|
document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview);
|
||||||
|
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user