Compare commits
2 Commits
d7d6e9d62c
...
db37040399
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db37040399 | ||
|
|
7ded78f2c3 |
@@ -293,6 +293,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
|
||||
configs.POST("", configHandler.Create)
|
||||
configs.GET("/:uuid", configHandler.Get)
|
||||
configs.PUT("/:uuid", configHandler.Update)
|
||||
configs.PATCH("/:uuid/rename", configHandler.Rename)
|
||||
configs.DELETE("/:uuid", configHandler.Delete)
|
||||
configs.GET("/:uuid/export", configHandler.ExportJSON)
|
||||
configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -123,17 +124,53 @@ func (h *ConfigurationHandler) Delete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
type RenameConfigRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Rename(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
var req RenameConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.Rename(uuid, userID, req.Name)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
status = http.StatusNotFound
|
||||
} else if err == services.ErrConfigForbidden {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
config, err := h.configService.GetByUUID(uuid, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.configService.ExportJSON(uuid, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", "attachment; filename=config.json")
|
||||
filename := fmt.Sprintf("%s %s SPEC.json", config.CreatedAt.Format("2006-01-02"), config.Name)
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Data(http.StatusOK, "application/json", data)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s_%s.csv", req.Name, time.Now().Format("20060102"))
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name)
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
}
|
||||
|
||||
@@ -101,8 +101,8 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s_%s.csv", config.Name, config.CreatedAt.Format("20060102"))
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,31 @@ import (
|
||||
"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
|
||||
@@ -62,8 +88,10 @@ func (h *PricingHandler) ListComponents(c *gin.Context) {
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
filter := repository.ComponentFilter{
|
||||
Category: c.Query("category"),
|
||||
Search: c.Query("search"),
|
||||
Category: c.Query("category"),
|
||||
Search: c.Query("search"),
|
||||
SortField: c.Query("sort"),
|
||||
SortDir: c.Query("dir"),
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
@@ -203,52 +231,57 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
||||
}
|
||||
|
||||
periodDays := comp.PricePeriodDays
|
||||
usedAllTime := false
|
||||
var result struct {
|
||||
Price *float64
|
||||
method := comp.PriceMethod
|
||||
if method == "" {
|
||||
method = models.PriceMethodMedian
|
||||
}
|
||||
|
||||
// First try with configured period
|
||||
// Get prices based on period
|
||||
var prices []float64
|
||||
if periodDays > 0 {
|
||||
query := `SELECT AVG(price) as price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`
|
||||
h.db.Raw(query, lotName, periodDays).Scan(&result)
|
||||
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)
|
||||
|
||||
// If no prices found in period, fall back to all time
|
||||
if result.Price == nil || *result.Price <= 0 {
|
||||
query = `SELECT AVG(price) as price FROM lot_log WHERE lot = ?`
|
||||
h.db.Raw(query, lotName).Scan(&result)
|
||||
if result.Price != nil && *result.Price > 0 {
|
||||
usedAllTime = true
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
} else {
|
||||
query := `SELECT AVG(price) as price FROM lot_log WHERE lot = ?`
|
||||
h.db.Raw(query, lotName).Scan(&result)
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices)
|
||||
}
|
||||
|
||||
if result.Price == nil || *result.Price <= 0 {
|
||||
if len(prices) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
finalPrice := *result.Price
|
||||
// Calculate price based on method
|
||||
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()
|
||||
updates := map[string]interface{}{
|
||||
"current_price": finalPrice,
|
||||
"price_updated_at": now,
|
||||
}
|
||||
|
||||
// If we fell back to all time, update the period setting
|
||||
if usedAllTime {
|
||||
updates["price_period_days"] = 0
|
||||
}
|
||||
|
||||
// Only update price, preserve all user settings
|
||||
h.db.Model(&models.LotMetadata{}).
|
||||
Where("lot_name = ?", lotName).
|
||||
Updates(updates)
|
||||
Updates(map[string]interface{}{
|
||||
"current_price": finalPrice,
|
||||
"price_updated_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
@@ -266,115 +299,96 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"})
|
||||
c.Writer.Flush()
|
||||
|
||||
lotNames := make([]string, 0, len(components))
|
||||
for i := range components {
|
||||
lotNames = append(lotNames, components[i].LotName)
|
||||
}
|
||||
|
||||
// Batch query: Get average prices with date info for all lots
|
||||
// This query returns both all-time average and days since last quote
|
||||
type PriceResult struct {
|
||||
Lot string `gorm:"column:lot"`
|
||||
AvgPrice *float64 `gorm:"column:avg_price"`
|
||||
AvgPrice90 *float64 `gorm:"column:avg_price_90"`
|
||||
LastQuoteAge *int `gorm:"column:last_quote_age"`
|
||||
}
|
||||
var priceResults []PriceResult
|
||||
h.db.Raw(`
|
||||
SELECT
|
||||
lot,
|
||||
AVG(price) as avg_price,
|
||||
AVG(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 90 DAY) THEN price END) as avg_price_90,
|
||||
DATEDIFF(NOW(), MAX(date)) as last_quote_age
|
||||
FROM lot_log
|
||||
WHERE lot IN ?
|
||||
GROUP BY lot
|
||||
`, lotNames).Scan(&priceResults)
|
||||
|
||||
// Build price maps
|
||||
priceMap := make(map[string]PriceResult, len(priceResults))
|
||||
for _, p := range priceResults {
|
||||
priceMap[p.Lot] = p
|
||||
}
|
||||
|
||||
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "processing", "updated": 0, "skipped": 0, "manual": 0, "errors": 0})
|
||||
c.Writer.Flush()
|
||||
|
||||
// Process components and prepare batch updates
|
||||
// Process components individually to respect their settings
|
||||
var updated, skipped, manual, errors int
|
||||
now := time.Now()
|
||||
batchSize := 200
|
||||
|
||||
for i := 0; i < len(components); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(components) {
|
||||
end = len(components)
|
||||
}
|
||||
batch := components[i:end]
|
||||
|
||||
// Start transaction for batch updates
|
||||
tx := h.db.Begin()
|
||||
|
||||
for _, comp := range batch {
|
||||
// If manual price is set, use it
|
||||
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
||||
err := tx.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++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Get price data for this component
|
||||
priceData, hasPrices := priceMap[comp.LotName]
|
||||
if !hasPrices || priceData.AvgPrice == nil || *priceData.AvgPrice <= 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine which price to use
|
||||
var finalPrice float64
|
||||
usedAllTime := false
|
||||
periodDays := comp.PricePeriodDays
|
||||
|
||||
if periodDays <= 0 {
|
||||
// Already using all time
|
||||
finalPrice = *priceData.AvgPrice
|
||||
for i, comp := range components {
|
||||
// If manual price is set, use it
|
||||
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 {
|
||||
// Try period price first (using 90-day as proxy)
|
||||
if priceData.AvgPrice90 != nil && *priceData.AvgPrice90 > 0 {
|
||||
finalPrice = *priceData.AvgPrice90
|
||||
} else {
|
||||
// Fall back to all time
|
||||
finalPrice = *priceData.AvgPrice
|
||||
usedAllTime = true
|
||||
}
|
||||
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}
|
||||
} else {
|
||||
query = `SELECT price FROM lot_log WHERE lot = ? ORDER BY price`
|
||||
args = []interface{}{comp.LotName}
|
||||
}
|
||||
|
||||
var prices []float64
|
||||
h.db.Raw(query, args...).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)
|
||||
}
|
||||
|
||||
if len(prices) == 0 {
|
||||
skipped++
|
||||
goto sendProgress
|
||||
}
|
||||
|
||||
// Calculate price based on method
|
||||
switch method {
|
||||
case models.PriceMethodMedian:
|
||||
median := calculateMedian(prices)
|
||||
basePrice = &median
|
||||
case models.PriceMethodAverage:
|
||||
avg := calculateAverage(prices)
|
||||
basePrice = &avg
|
||||
default:
|
||||
median := calculateMedian(prices)
|
||||
basePrice = &median
|
||||
}
|
||||
|
||||
if basePrice == nil || *basePrice <= 0 {
|
||||
skipped++
|
||||
goto sendProgress
|
||||
}
|
||||
|
||||
finalPrice := *basePrice
|
||||
|
||||
// Apply coefficient
|
||||
if comp.PriceCoefficient != 0 {
|
||||
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"current_price": finalPrice,
|
||||
"price_updated_at": now,
|
||||
}
|
||||
|
||||
if usedAllTime {
|
||||
updates["price_period_days"] = 0
|
||||
}
|
||||
|
||||
err := tx.Model(&models.LotMetadata{}).
|
||||
// Update only price fields
|
||||
err := h.db.Model(&models.LotMetadata{}).
|
||||
Where("lot_name = ?", comp.LotName).
|
||||
Updates(updates).Error
|
||||
Updates(map[string]interface{}{
|
||||
"current_price": finalPrice,
|
||||
"price_updated_at": now,
|
||||
}).Error
|
||||
if err != nil {
|
||||
errors++
|
||||
} else {
|
||||
@@ -382,24 +396,25 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Commit batch
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
errors += len(batch)
|
||||
sendProgress:
|
||||
// Send progress update every 50 components
|
||||
if (i+1)%50 == 0 || i == len(components)-1 {
|
||||
c.SSEvent("progress", gin.H{
|
||||
"current": updated + skipped + manual + errors,
|
||||
"total": total,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"manual": manual,
|
||||
"errors": errors,
|
||||
"status": "processing",
|
||||
})
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// Send progress update
|
||||
c.SSEvent("progress", gin.H{
|
||||
"current": updated + skipped + manual + errors,
|
||||
"total": total,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"manual": manual,
|
||||
"errors": errors,
|
||||
"status": "processing",
|
||||
})
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// Update popularity scores
|
||||
h.statsRepo.UpdatePopularityScores()
|
||||
|
||||
// Send completion
|
||||
c.SSEvent("progress", gin.H{
|
||||
"current": updated + skipped + manual + errors,
|
||||
@@ -504,43 +519,69 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get median for all time
|
||||
var medianAllTime struct {
|
||||
Price *float64
|
||||
// Get all prices for calculations
|
||||
var allPrices []float64
|
||||
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, req.LotName).Pluck("price", &allPrices)
|
||||
|
||||
// Calculate median for all time
|
||||
var medianAllTime *float64
|
||||
if len(allPrices) > 0 {
|
||||
median := calculateMedian(allPrices)
|
||||
medianAllTime = &median
|
||||
}
|
||||
h.db.Raw(`SELECT AVG(price) as price FROM lot_log WHERE lot = ?`, req.LotName).Scan(&medianAllTime)
|
||||
|
||||
// Get quote count
|
||||
var quoteCount int64
|
||||
h.db.Model(&models.LotLog{}).Where("lot = ?", req.LotName).Count("eCount)
|
||||
|
||||
// Calculate new price based on parameters
|
||||
var basePrice *float64
|
||||
// Get last received price
|
||||
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 {
|
||||
var result struct {
|
||||
Price *float64
|
||||
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)
|
||||
// Fall back to all time if no prices in period
|
||||
if len(prices) == 0 {
|
||||
prices = allPrices
|
||||
}
|
||||
h.db.Raw(`SELECT AVG(price) as price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, req.LotName, req.PeriodDays).Scan(&result)
|
||||
basePrice = result.Price
|
||||
} else {
|
||||
basePrice = medianAllTime.Price
|
||||
prices = allPrices
|
||||
}
|
||||
|
||||
var newPrice *float64
|
||||
if basePrice != nil && *basePrice > 0 {
|
||||
calculated := *basePrice
|
||||
if req.Coefficient != 0 {
|
||||
calculated = calculated * (1 + req.Coefficient/100)
|
||||
if len(prices) > 0 {
|
||||
var basePrice float64
|
||||
if method == "average" {
|
||||
basePrice = calculateAverage(prices)
|
||||
} else {
|
||||
basePrice = calculateMedian(prices)
|
||||
}
|
||||
newPrice = &calculated
|
||||
|
||||
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.Price,
|
||||
"median_all_time": medianAllTime,
|
||||
"new_price": newPrice,
|
||||
"quote_count": quoteCount,
|
||||
"manual_price": comp.ManualPrice,
|
||||
"last_price": lastPrice.Price,
|
||||
"last_price_date": lastPrice.Date,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,9 +16,11 @@ func NewComponentRepository(db *gorm.DB) *ComponentRepository {
|
||||
}
|
||||
|
||||
type ComponentFilter struct {
|
||||
Category string
|
||||
Search string
|
||||
HasPrice bool
|
||||
Category string
|
||||
Search string
|
||||
HasPrice bool
|
||||
SortField string
|
||||
SortDir string
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
@@ -43,10 +45,33 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
|
||||
|
||||
query.Count(&total)
|
||||
|
||||
// Sort by popularity + freshness, no price goes last
|
||||
// Apply sorting
|
||||
sortDir := "ASC"
|
||||
if filter.SortDir == "desc" {
|
||||
sortDir = "DESC"
|
||||
}
|
||||
|
||||
switch filter.SortField {
|
||||
case "popularity_score":
|
||||
query = query.Order("popularity_score " + sortDir)
|
||||
case "current_price":
|
||||
query = query.Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
||||
Order("current_price " + sortDir)
|
||||
case "lot_name":
|
||||
query = query.Order("lot_name " + sortDir)
|
||||
case "quote_count":
|
||||
// Sort by quote count from lot_log table
|
||||
query = query.
|
||||
Select("qt_lot_metadata.*, (SELECT COUNT(*) FROM lot_log WHERE lot_log.lot = qt_lot_metadata.lot_name) as quote_count_sort").
|
||||
Order("quote_count_sort " + sortDir)
|
||||
default:
|
||||
// Default: sort by popularity, no price goes last
|
||||
query = query.
|
||||
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
||||
Order("popularity_score DESC")
|
||||
}
|
||||
|
||||
err := query.
|
||||
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
||||
Order("popularity_score DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&components).Error
|
||||
|
||||
@@ -90,3 +90,26 @@ func (r *StatsRepository) ResetMonthlyCounters() error {
|
||||
Where("1 = 1").
|
||||
Update("quotes_last_30d", 0).Error
|
||||
}
|
||||
|
||||
// UpdatePopularityScores recalculates popularity_score in qt_lot_metadata
|
||||
// based on supplier quotes from lot_log table
|
||||
func (r *StatsRepository) UpdatePopularityScores() error {
|
||||
// Formula: popularity_score = quotes_last_30d * 3 + quotes_last_90d * 1 + quotes_total * 0.1
|
||||
// This gives more weight to recent supplier activity
|
||||
return r.db.Exec(`
|
||||
UPDATE qt_lot_metadata m
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
lot,
|
||||
COUNT(*) as quotes_total,
|
||||
SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as quotes_last_30d,
|
||||
SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 90 DAY) THEN 1 ELSE 0 END) as quotes_last_90d
|
||||
FROM lot_log
|
||||
GROUP BY lot
|
||||
) s ON m.lot_name = s.lot
|
||||
SET m.popularity_score = COALESCE(
|
||||
s.quotes_last_30d * 3 + s.quotes_last_90d * 1 + s.quotes_total * 0.1,
|
||||
0
|
||||
)
|
||||
`).Error
|
||||
}
|
||||
|
||||
@@ -114,6 +114,25 @@ func (s *ConfigurationService) Delete(uuid string, userID uint) error {
|
||||
return s.configRepo.Delete(config.ID)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if config.UserID != userID {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
config.Name = newName
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
<span class="text-sm text-gray-500">Сортировка:</span>
|
||||
<select id="sort-field" class="px-2 py-1 border rounded text-sm" onchange="changeSort()">
|
||||
<option value="lot_name">Артикул</option>
|
||||
<option value="popularity_score">Популярность</option>
|
||||
<option value="popularity_score" selected>Популярность</option>
|
||||
<option value="quote_count">Кол-во котировок</option>
|
||||
<option value="current_price">Цена</option>
|
||||
</select>
|
||||
<button onclick="toggleSortDir()" id="sort-dir-btn" class="px-2 py-1 border rounded text-sm">↑</button>
|
||||
<button onclick="toggleSortDir()" id="sort-dir-btn" class="px-2 py-1 border rounded text-sm">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,6 +113,8 @@
|
||||
<div class="bg-gray-50 p-3 rounded space-y-2">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">Расчёт цены</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div class="text-gray-600">Последняя цена:</div>
|
||||
<div id="modal-last-price" class="font-medium text-right">—</div>
|
||||
<div class="text-gray-600">Медиана (всё время):</div>
|
||||
<div id="modal-median-all" class="font-medium text-right">—</div>
|
||||
<div class="text-gray-600">Текущая цена:</div>
|
||||
@@ -140,8 +142,8 @@ let perPage = 50;
|
||||
let searchTimeout = null;
|
||||
let currentSearch = '';
|
||||
let componentsCache = [];
|
||||
let sortField = 'lot_name';
|
||||
let sortDir = 'asc';
|
||||
let sortField = 'popularity_score';
|
||||
let sortDir = 'desc';
|
||||
|
||||
async function loadTab(tab) {
|
||||
currentTab = tab;
|
||||
@@ -180,6 +182,12 @@ async function loadData() {
|
||||
if (currentSearch) {
|
||||
url += '&search=' + encodeURIComponent(currentSearch);
|
||||
}
|
||||
if (sortField) {
|
||||
url += '&sort=' + encodeURIComponent(sortField);
|
||||
}
|
||||
if (sortDir) {
|
||||
url += '&dir=' + encodeURIComponent(sortDir);
|
||||
}
|
||||
const resp = await fetch(url, {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
@@ -249,33 +257,6 @@ function renderComponents(components, total) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort components locally
|
||||
const sorted = [...components].sort((a, b) => {
|
||||
let aVal, bVal;
|
||||
switch (sortField) {
|
||||
case 'popularity_score':
|
||||
aVal = a.popularity_score || 0;
|
||||
bVal = b.popularity_score || 0;
|
||||
break;
|
||||
case 'quote_count':
|
||||
aVal = a.quote_count || 0;
|
||||
bVal = b.quote_count || 0;
|
||||
break;
|
||||
case 'current_price':
|
||||
aVal = a.current_price || 0;
|
||||
bVal = b.current_price || 0;
|
||||
break;
|
||||
default:
|
||||
aVal = a.lot_name || '';
|
||||
bVal = b.lot_name || '';
|
||||
}
|
||||
if (sortDir === 'asc') {
|
||||
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
|
||||
} else {
|
||||
return aVal < bVal ? 1 : aVal > bVal ? -1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
let html = '<div class="overflow-x-auto"><table class="w-full"><thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
|
||||
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>';
|
||||
@@ -286,7 +267,7 @@ function renderComponents(components, total) {
|
||||
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>';
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
|
||||
sorted.forEach((c, idx) => {
|
||||
components.forEach((c, idx) => {
|
||||
const price = c.current_price ? '$' + parseFloat(c.current_price).toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
||||
const category = c.category ? c.category.code : '—';
|
||||
const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : '—';
|
||||
@@ -311,10 +292,7 @@ function renderComponents(components, total) {
|
||||
settings.push('РУЧН');
|
||||
}
|
||||
|
||||
// Find original index in componentsCache
|
||||
const origIdx = componentsCache.findIndex(x => x.lot_name === c.lot_name);
|
||||
|
||||
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + origIdx + ')">';
|
||||
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">' + escapeHtml(category) + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate">' + escapeHtml(desc) + '</td>';
|
||||
@@ -352,6 +330,7 @@ function openModal(idx) {
|
||||
document.getElementById('modal-manual-price').disabled = !hasManual;
|
||||
|
||||
// Reset price displays while loading
|
||||
document.getElementById('modal-last-price').textContent = '...';
|
||||
document.getElementById('modal-median-all').textContent = '...';
|
||||
document.getElementById('modal-current-price').textContent = '...';
|
||||
document.getElementById('modal-new-price').textContent = '...';
|
||||
@@ -393,6 +372,18 @@ async function fetchPreview() {
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
|
||||
// Update last price with date
|
||||
if (data.last_price) {
|
||||
let lastPriceText = '$' + parseFloat(data.last_price).toFixed(2);
|
||||
if (data.last_price_date) {
|
||||
const date = new Date(data.last_price_date);
|
||||
lastPriceText += ' (' + date.toLocaleDateString('ru-RU') + ')';
|
||||
}
|
||||
document.getElementById('modal-last-price').textContent = lastPriceText;
|
||||
} else {
|
||||
document.getElementById('modal-last-price').textContent = '—';
|
||||
}
|
||||
|
||||
// Update median all time
|
||||
document.getElementById('modal-median-all').textContent =
|
||||
data.median_all_time ? '$' + parseFloat(data.median_all_time).toFixed(2) : '—';
|
||||
@@ -410,7 +401,9 @@ async function fetchPreview() {
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Preview fetch error:', e);
|
||||
document.getElementById('modal-last-price').textContent = '—';
|
||||
document.getElementById('modal-median-all').textContent = '—';
|
||||
document.getElementById('modal-current-price').textContent = '—';
|
||||
document.getElementById('modal-new-price').textContent = '—';
|
||||
}
|
||||
}
|
||||
@@ -584,13 +577,15 @@ document.getElementById('price-modal').addEventListener('click', function(e) {
|
||||
|
||||
function changeSort() {
|
||||
sortField = document.getElementById('sort-field').value;
|
||||
renderComponents(componentsCache, componentsCache.length);
|
||||
currentPage = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function toggleSortDir() {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
document.getElementById('sort-dir-btn').textContent = sortDir === 'asc' ? '↑' : '↓';
|
||||
renderComponents(componentsCache, componentsCache.length);
|
||||
currentPage = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@@ -39,6 +39,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for renaming configuration -->
|
||||
<div id="rename-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Переименовать конфигурацию</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Новое название</label>
|
||||
<input type="text" id="rename-input" placeholder="Введите новое название"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<input type="hidden" id="rename-uuid">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeRenameModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
||||
Отмена
|
||||
</button>
|
||||
<button onclick="renameConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadConfigs() {
|
||||
const token = localStorage.getItem('token');
|
||||
@@ -95,7 +120,8 @@ function renderConfigs(configs) {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right">';
|
||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800">Переименовать</button>';
|
||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800">Удалить</button>';
|
||||
html += '</td></tr>';
|
||||
});
|
||||
@@ -120,6 +146,63 @@ async function deleteConfig(uuid) {
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
function openRenameModal(uuid, currentName) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
document.getElementById('rename-uuid').value = uuid;
|
||||
document.getElementById('rename-input').value = currentName;
|
||||
document.getElementById('rename-modal').classList.remove('hidden');
|
||||
document.getElementById('rename-modal').classList.add('flex');
|
||||
document.getElementById('rename-input').focus();
|
||||
document.getElementById('rename-input').select();
|
||||
}
|
||||
|
||||
function closeRenameModal() {
|
||||
document.getElementById('rename-modal').classList.add('hidden');
|
||||
document.getElementById('rename-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function renameConfig() {
|
||||
const token = localStorage.getItem('token');
|
||||
const uuid = document.getElementById('rename-uuid').value;
|
||||
const name = document.getElementById('rename-input').value.trim();
|
||||
|
||||
if (!name) {
|
||||
alert('Введите название');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + uuid + '/rename', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ name: name })
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
alert('Ошибка: ' + (err.error || 'Не удалось переименовать'));
|
||||
return;
|
||||
}
|
||||
|
||||
closeRenameModal();
|
||||
loadConfigs();
|
||||
} catch(e) {
|
||||
alert('Ошибка переименования');
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
@@ -185,10 +268,24 @@ document.getElementById('create-modal').addEventListener('click', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('rename-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeRenameModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeCreateModal();
|
||||
closeRenameModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Submit rename on Enter key
|
||||
document.getElementById('rename-input').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
renameConfig();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -69,6 +69,66 @@
|
||||
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom price section -->
|
||||
<div id="custom-price-section" class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold mb-3">Своя цена</h3>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm text-gray-600 mb-1">Введите целевую цену</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500">$</span>
|
||||
<input type="number" id="custom-price-input" step="0.01" min="0"
|
||||
placeholder="0.00"
|
||||
class="flex-1 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="calculateCustomPrice()">
|
||||
<button onclick="clearCustomPrice()" class="px-3 py-2 text-gray-500 hover:text-gray-700">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="discount-info" class="text-right hidden">
|
||||
<div class="text-sm text-gray-600">Скидка</div>
|
||||
<div class="text-2xl font-bold text-green-600" id="discount-percent">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adjusted prices table -->
|
||||
<div id="adjusted-prices" class="hidden">
|
||||
<div class="border-t pt-3">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Скорректированные цены</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Компонент</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Было</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Стало</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Итого</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="adjusted-prices-body" class="divide-y"></tbody>
|
||||
<tfoot class="bg-gray-50 font-medium">
|
||||
<tr>
|
||||
<td class="px-3 py-2" colspan="2">Итого</td>
|
||||
<td class="px-3 py-2 text-right" id="adjusted-total-original">$0.00</td>
|
||||
<td class="px-3 py-2 text-right text-green-600" id="adjusted-total-new">$0.00</td>
|
||||
<td class="px-3 py-2 text-right text-green-600" id="adjusted-total-final">$0.00</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button onclick="exportCSVWithCustomPrice()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||
Экспорт CSV со скидкой
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Autocomplete dropdown (shared) -->
|
||||
@@ -643,6 +703,9 @@ function updateCartUI() {
|
||||
const total = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||
document.getElementById('cart-total').textContent = '$' + total.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
|
||||
// Recalculate custom price section if active
|
||||
calculateCustomPrice();
|
||||
|
||||
if (cart.length === 0) {
|
||||
document.getElementById('cart-items').innerHTML =
|
||||
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
|
||||
@@ -752,6 +815,113 @@ async function exportCSV() {
|
||||
}
|
||||
}
|
||||
|
||||
// Custom price functionality
|
||||
function calculateCustomPrice() {
|
||||
const customPriceInput = document.getElementById('custom-price-input');
|
||||
const customPrice = parseFloat(customPriceInput.value) || 0;
|
||||
|
||||
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||
|
||||
if (customPrice <= 0 || cart.length === 0 || originalTotal <= 0) {
|
||||
document.getElementById('adjusted-prices').classList.add('hidden');
|
||||
document.getElementById('discount-info').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate discount percentage
|
||||
const discountPercent = ((originalTotal - customPrice) / originalTotal) * 100;
|
||||
const coefficient = customPrice / originalTotal;
|
||||
|
||||
// Show discount info
|
||||
document.getElementById('discount-info').classList.remove('hidden');
|
||||
document.getElementById('discount-percent').textContent = discountPercent.toFixed(1) + '%';
|
||||
|
||||
// Update discount color based on value
|
||||
const discountEl = document.getElementById('discount-percent');
|
||||
if (discountPercent > 0) {
|
||||
discountEl.className = 'text-2xl font-bold text-green-600';
|
||||
} else if (discountPercent < 0) {
|
||||
discountEl.className = 'text-2xl font-bold text-red-600';
|
||||
} else {
|
||||
discountEl.className = 'text-2xl font-bold text-gray-600';
|
||||
}
|
||||
|
||||
// Build adjusted prices table
|
||||
let html = '';
|
||||
let totalOriginal = 0;
|
||||
let totalNew = 0;
|
||||
|
||||
cart.forEach(item => {
|
||||
const originalPrice = item.unit_price;
|
||||
const newPrice = originalPrice * coefficient;
|
||||
const itemOriginalTotal = originalPrice * item.quantity;
|
||||
const itemNewTotal = newPrice * item.quantity;
|
||||
|
||||
totalOriginal += itemOriginalTotal;
|
||||
totalNew += itemNewTotal;
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
|
||||
<td class="px-3 py-2 text-right">${item.quantity}</td>
|
||||
<td class="px-3 py-2 text-right text-gray-500">$${originalPrice.toFixed(2)}</td>
|
||||
<td class="px-3 py-2 text-right text-green-600">$${newPrice.toFixed(2)}</td>
|
||||
<td class="px-3 py-2 text-right">$${itemNewTotal.toFixed(2)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
document.getElementById('adjusted-prices-body').innerHTML = html;
|
||||
document.getElementById('adjusted-total-original').textContent = '$' + totalOriginal.toFixed(2);
|
||||
document.getElementById('adjusted-total-new').textContent = '$' + totalNew.toFixed(2);
|
||||
document.getElementById('adjusted-total-final').textContent = '$' + totalNew.toFixed(2);
|
||||
document.getElementById('adjusted-prices').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearCustomPrice() {
|
||||
document.getElementById('custom-price-input').value = '';
|
||||
document.getElementById('adjusted-prices').classList.add('hidden');
|
||||
document.getElementById('discount-info').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function exportCSVWithCustomPrice() {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
|
||||
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||
|
||||
if (customPrice <= 0 || originalTotal <= 0) {
|
||||
showToast('Введите целевую цену', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const coefficient = customPrice / originalTotal;
|
||||
|
||||
// Create adjusted cart
|
||||
const adjustedCart = cart.map(item => ({
|
||||
...item,
|
||||
unit_price: parseFloat((item.unit_price * coefficient).toFixed(2))
|
||||
}));
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/export/csv', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({items: adjustedCart, name: configName})
|
||||
});
|
||||
|
||||
const blob = await resp.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (configName || 'config') + '.csv';
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch(e) {
|
||||
showToast('Ошибка экспорта', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user