Исправления расчёта цен и добавление функционала своей цены
- Исправлен расчёт цен: теперь учитывается метод (медиана/среднее) и период для каждого компонента - Добавлены функции calculateMedian и calculateAverage - Исправлен PreviewPrice для корректного предпросмотра с учётом настроек - Сортировка по умолчанию изменена на популярность (desc) - Добавлен раздел "Своя цена" в конфигуратор: - Ввод целевой цены с пропорциональным пересчётом всех позиций - Отображение скидки в процентах - Таблица скорректированных цен - Экспорт CSV со скидкой Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,6 +14,31 @@ import (
|
|||||||
"gorm.io/gorm"
|
"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 {
|
type PricingHandler struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
pricingService *pricing.Service
|
pricingService *pricing.Service
|
||||||
@@ -205,30 +231,45 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
periodDays := comp.PricePeriodDays
|
periodDays := comp.PricePeriodDays
|
||||||
var result struct {
|
method := comp.PriceMethod
|
||||||
Price *float64
|
if method == "" {
|
||||||
|
method = models.PriceMethodMedian
|
||||||
}
|
}
|
||||||
|
|
||||||
// First try with configured period
|
// Get prices based on period
|
||||||
|
var prices []float64
|
||||||
if periodDays > 0 {
|
if periodDays > 0 {
|
||||||
query := `SELECT AVG(price) as price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||||
h.db.Raw(query, lotName, periodDays).Scan(&result)
|
lotName, periodDays).Pluck("price", &prices)
|
||||||
|
|
||||||
// If no prices found in period, fall back to all time but keep user's period setting
|
// If no prices in period, try all time
|
||||||
if result.Price == nil || *result.Price <= 0 {
|
if len(prices) == 0 {
|
||||||
query = `SELECT AVG(price) as price FROM lot_log WHERE lot = ?`
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices)
|
||||||
h.db.Raw(query, lotName).Scan(&result)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
query := `SELECT AVG(price) as price FROM lot_log WHERE lot = ?`
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices)
|
||||||
h.db.Raw(query, lotName).Scan(&result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Price == nil || *result.Price <= 0 {
|
if len(prices) == 0 {
|
||||||
return
|
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 {
|
if comp.PriceCoefficient != 0 {
|
||||||
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
||||||
}
|
}
|
||||||
@@ -258,110 +299,96 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
|||||||
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()
|
||||||
|
|
||||||
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.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "processing", "updated": 0, "skipped": 0, "manual": 0, "errors": 0})
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
|
|
||||||
// Process components and prepare batch updates
|
// Process components individually to respect their settings
|
||||||
var updated, skipped, manual, errors int
|
var updated, skipped, manual, errors int
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
batchSize := 200
|
|
||||||
|
|
||||||
for i := 0; i < len(components); i += batchSize {
|
for i, comp := range components {
|
||||||
end := i + batchSize
|
// If manual price is set, use it
|
||||||
if end > len(components) {
|
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
||||||
end = len(components)
|
err := h.db.Model(&models.LotMetadata{}).
|
||||||
}
|
Where("lot_name = ?", comp.LotName).
|
||||||
batch := components[i:end]
|
Updates(map[string]interface{}{
|
||||||
|
"current_price": *comp.ManualPrice,
|
||||||
// Start transaction for batch updates
|
"price_updated_at": now,
|
||||||
tx := h.db.Begin()
|
}).Error
|
||||||
|
if err != nil {
|
||||||
for _, comp := range batch {
|
errors++
|
||||||
// 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 based on component settings
|
|
||||||
var finalPrice float64
|
|
||||||
periodDays := comp.PricePeriodDays
|
|
||||||
|
|
||||||
if periodDays <= 0 {
|
|
||||||
// Use all time
|
|
||||||
finalPrice = *priceData.AvgPrice
|
|
||||||
} else {
|
} else {
|
||||||
// Try period price first (using 90-day as proxy)
|
manual++
|
||||||
if priceData.AvgPrice90 != nil && *priceData.AvgPrice90 > 0 {
|
|
||||||
finalPrice = *priceData.AvgPrice90
|
|
||||||
} else {
|
|
||||||
// Fall back to all time if no data in period, but keep user's period setting
|
|
||||||
finalPrice = *priceData.AvgPrice
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
// Apply coefficient
|
||||||
if comp.PriceCoefficient != 0 {
|
if comp.PriceCoefficient != 0 {
|
||||||
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update current_price and price_updated_at, preserve all other settings
|
// Update only price fields
|
||||||
updates := map[string]interface{}{
|
err := h.db.Model(&models.LotMetadata{}).
|
||||||
"current_price": finalPrice,
|
|
||||||
"price_updated_at": now,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := tx.Model(&models.LotMetadata{}).
|
|
||||||
Where("lot_name = ?", comp.LotName).
|
Where("lot_name = ?", comp.LotName).
|
||||||
Updates(updates).Error
|
Updates(map[string]interface{}{
|
||||||
|
"current_price": finalPrice,
|
||||||
|
"price_updated_at": now,
|
||||||
|
}).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors++
|
errors++
|
||||||
} else {
|
} else {
|
||||||
@@ -369,22 +396,20 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit batch
|
sendProgress:
|
||||||
if err := tx.Commit().Error; err != nil {
|
// Send progress update every 50 components
|
||||||
errors += len(batch)
|
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
|
// Update popularity scores
|
||||||
@@ -494,11 +519,16 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get median for all time
|
// Get all prices for calculations
|
||||||
var medianAllTime struct {
|
var allPrices []float64
|
||||||
Price *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
|
// Get quote count
|
||||||
var quoteCount int64
|
var quoteCount int64
|
||||||
@@ -511,31 +541,43 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
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)
|
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
|
// Calculate new price based on parameters (method, period, coefficient)
|
||||||
var basePrice *float64
|
method := req.Method
|
||||||
|
if method == "" {
|
||||||
|
method = "median"
|
||||||
|
}
|
||||||
|
|
||||||
|
var prices []float64
|
||||||
if req.PeriodDays > 0 {
|
if req.PeriodDays > 0 {
|
||||||
var result struct {
|
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||||
Price *float64
|
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 {
|
} else {
|
||||||
basePrice = medianAllTime.Price
|
prices = allPrices
|
||||||
}
|
}
|
||||||
|
|
||||||
var newPrice *float64
|
var newPrice *float64
|
||||||
if basePrice != nil && *basePrice > 0 {
|
if len(prices) > 0 {
|
||||||
calculated := *basePrice
|
var basePrice float64
|
||||||
if req.Coefficient != 0 {
|
if method == "average" {
|
||||||
calculated = calculated * (1 + req.Coefficient/100)
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"lot_name": req.LotName,
|
"lot_name": req.LotName,
|
||||||
"current_price": comp.CurrentPrice,
|
"current_price": comp.CurrentPrice,
|
||||||
"median_all_time": medianAllTime.Price,
|
"median_all_time": medianAllTime,
|
||||||
"new_price": newPrice,
|
"new_price": newPrice,
|
||||||
"quote_count": quoteCount,
|
"quote_count": quoteCount,
|
||||||
"manual_price": comp.ManualPrice,
|
"manual_price": comp.ManualPrice,
|
||||||
|
|||||||
@@ -39,11 +39,11 @@
|
|||||||
<span class="text-sm text-gray-500">Сортировка:</span>
|
<span class="text-sm text-gray-500">Сортировка:</span>
|
||||||
<select id="sort-field" class="px-2 py-1 border rounded text-sm" onchange="changeSort()">
|
<select id="sort-field" class="px-2 py-1 border rounded text-sm" onchange="changeSort()">
|
||||||
<option value="lot_name">Артикул</option>
|
<option value="lot_name">Артикул</option>
|
||||||
<option value="popularity_score">Популярность</option>
|
<option value="popularity_score" selected>Популярность</option>
|
||||||
<option value="quote_count">Кол-во котировок</option>
|
<option value="quote_count">Кол-во котировок</option>
|
||||||
<option value="current_price">Цена</option>
|
<option value="current_price">Цена</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,8 +142,8 @@ let perPage = 50;
|
|||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
let currentSearch = '';
|
let currentSearch = '';
|
||||||
let componentsCache = [];
|
let componentsCache = [];
|
||||||
let sortField = 'lot_name';
|
let sortField = 'popularity_score';
|
||||||
let sortDir = 'asc';
|
let sortDir = 'desc';
|
||||||
|
|
||||||
async function loadTab(tab) {
|
async function loadTab(tab) {
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
|
|||||||
@@ -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>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Autocomplete dropdown (shared) -->
|
<!-- Autocomplete dropdown (shared) -->
|
||||||
@@ -643,6 +703,9 @@ function updateCartUI() {
|
|||||||
const total = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
const total = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||||
document.getElementById('cart-total').textContent = '$' + total.toLocaleString('en-US', {minimumFractionDigits: 2});
|
document.getElementById('cart-total').textContent = '$' + total.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||||
|
|
||||||
|
// Recalculate custom price section if active
|
||||||
|
calculateCustomPrice();
|
||||||
|
|
||||||
if (cart.length === 0) {
|
if (cart.length === 0) {
|
||||||
document.getElementById('cart-items').innerHTML =
|
document.getElementById('cart-items').innerHTML =
|
||||||
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
|
'<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>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user