From 9bc01831c9c44ded3ac0eb68e9cb631661e39a76 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sun, 8 Feb 2026 12:25:38 +0300 Subject: [PATCH] feat: add pricelist CSV export and improve description display - Add CSV export functionality for pricelists with download button - Export includes all pricelist items with proper UTF-8 encoding - Support both warehouse and estimate pricelist sources - Remove description column from admin pricing tables - Show description as tooltip on row hover instead - Improve table layout by removing redundant column Co-Authored-By: Claude Sonnet 4.5 --- cmd/pfs/main.go | 1 + internal/handlers/pricelist.go | 159 ++++++++++++++++++++++++++++ web/templates/admin_pricing.html | 12 +-- web/templates/pricelist_detail.html | 54 ++++++++-- 4 files changed, 212 insertions(+), 14 deletions(-) diff --git a/cmd/pfs/main.go b/cmd/pfs/main.go index 45b1418..ba33943 100644 --- a/cmd/pfs/main.go +++ b/cmd/pfs/main.go @@ -565,6 +565,7 @@ func setupRouter(cfg *config.Config, configPath string, connMgr *db.ConnectionMa pricelists.GET("/:id", pricelistHandler.Get) pricelists.GET("/:id/items", pricelistHandler.GetItems) pricelists.GET("/:id/lots", pricelistHandler.GetLotNames) + pricelists.GET("/:id/export-csv", pricelistHandler.ExportCSV) pricelists.POST("", pricelistHandler.Create) pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress) pricelists.PATCH("/:id/active", pricelistHandler.SetActive) diff --git a/internal/handlers/pricelist.go b/internal/handlers/pricelist.go index 205e335..cb46071 100644 --- a/internal/handlers/pricelist.go +++ b/internal/handlers/pricelist.go @@ -1,7 +1,9 @@ package handlers import ( + "encoding/csv" "errors" + "fmt" "io" "net/http" "strconv" @@ -239,3 +241,160 @@ func (h *PricelistHandler) GetLatest(c *gin.Context) { } c.JSON(http.StatusOK, pl) } + +func (h *PricelistHandler) ExportCSV(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) + return + } + + // Get pricelist info + pl, err := h.service.GetByID(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"}) + return + } + + // Get all items (no pagination) + items, _, err := h.service.GetItems(uint(id), 1, 999999, "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Set response headers for CSV download + filename := fmt.Sprintf("pricelist_%s.csv", pl.Version) + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + // Create CSV writer + writer := csv.NewWriter(c.Writer) + defer writer.Flush() + + // Write UTF-8 BOM for Excel compatibility + c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) + + // Determine if warehouse source + isWarehouse := strings.ToLower(pl.Source) == "warehouse" + + // Write CSV header + var header []string + if isWarehouse { + header = []string{"Артикул", "Категория", "Описание", "Доступно", "Partnumbers", "Цена, $", "Настройки"} + } else { + header = []string{"Артикул", "Категория", "Описание", "Цена, $", "Настройки"} + } + if err := writer.Write(header); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write CSV header"}) + return + } + + // Write items + for _, item := range items { + row := make([]string, 0, len(header)) + + // Артикул + row = append(row, item.LotName) + + // Категория + category := item.Category + if category == "" { + category = "-" + } + row = append(row, category) + + // Описание + description := item.LotDescription + if description == "" { + description = "-" + } + row = append(row, description) + + if isWarehouse { + // Доступно + qty := "-" + if item.AvailableQty != nil { + qty = fmt.Sprintf("%.3f", *item.AvailableQty) + } + row = append(row, qty) + + // Partnumbers + partnumbers := "-" + if len(item.Partnumbers) > 0 { + partnumbers = strings.Join(item.Partnumbers, ", ") + } + row = append(row, partnumbers) + } + + // Цена + row = append(row, fmt.Sprintf("%.2f", item.Price)) + + // Настройки + settings := formatPriceSettings(item) + row = append(row, settings) + + if err := writer.Write(row); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write CSV row"}) + return + } + } +} + +func formatPriceSettings(item models.PricelistItem) string { + var settings []string + + hasManualPrice := item.ManualPrice != nil && *item.ManualPrice > 0 + hasMeta := item.MetaPrices != "" + method := strings.ToLower(item.PriceMethod) + + // Method indicator + if hasManualPrice { + settings = append(settings, "РУЧН") + } else if method == "average" { + settings = append(settings, "Сред") + } else if method == "weighted_median" { + settings = append(settings, "Взвеш. мед") + } else { + settings = append(settings, "Мед") + } + + // Period (only if not manual price) + if !hasManualPrice { + period := item.PricePeriodDays + switch period { + case 7: + settings = append(settings, "1н") + case 30: + settings = append(settings, "1м") + case 90: + settings = append(settings, "3м") + case 365: + settings = append(settings, "1г") + case 0: + settings = append(settings, "все") + default: + settings = append(settings, fmt.Sprintf("%dд", period)) + } + } + + // Coefficient + if item.PriceCoefficient != 0 { + coef := item.PriceCoefficient + if coef > 0 { + settings = append(settings, fmt.Sprintf("+%.0f%%", coef)) + } else { + settings = append(settings, fmt.Sprintf("%.0f%%", coef)) + } + } + + // Meta article indicator + if hasMeta { + settings = append(settings, "МЕТА") + } + + if len(settings) == 0 { + return "-" + } + return strings.Join(settings, " | ") +} diff --git a/web/templates/admin_pricing.html b/web/templates/admin_pricing.html index c74bce4..2eb1399 100644 --- a/web/templates/admin_pricing.html +++ b/web/templates/admin_pricing.html @@ -684,7 +684,6 @@ function renderLots(lots, total) { html += 'Категория'; html += 'LOT'; html += 'p/n'; - html += 'Описание'; html += 'Популярность'; html += 'Котировок'; html += 'Конкуренты'; @@ -694,7 +693,7 @@ function renderLots(lots, total) { lots.forEach(lot => { const category = lot.category ? escapeHtml(lot.category) : '—'; const lotName = lot.lot_name ? escapeHtml(lot.lot_name) : '—'; - const description = lot.lot_description ? escapeHtml(lot.lot_description) : '—'; + const description = lot.lot_description ? lot.lot_description : ''; const popularity = Number.isFinite(lot.popularity) ? Number(lot.popularity).toFixed(2) : '0.00'; const estimateCount = Number.isFinite(lot.estimate_count) ? lot.estimate_count.toLocaleString('ru-RU') : '0'; const stockQty = lot.stock_qty === null || lot.stock_qty === undefined @@ -706,11 +705,10 @@ function renderLots(lots, total) { ? `+${partnumbers.length - 1}` : ''; - html += ''; + html += ''; html += '' + category + ''; html += '' + lotName + ''; html += '' + firstPart + more + ''; - html += '' + description + ''; html += '' + popularity + ''; html += '' + estimateCount + ''; html += '—'; @@ -731,7 +729,6 @@ function renderComponents(components, total) { let html = '
'; html += ''; html += ''; - html += ''; html += ''; html += ''; html += ''; @@ -741,7 +738,7 @@ function renderComponents(components, total) { 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 : '—'; + const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : ''; const quoteCount = c.quote_count || 0; const popularity = c.popularity_score ? c.popularity_score.toFixed(2) : '0.00'; const isHidden = c.is_hidden || quoteCount === 0; @@ -807,10 +804,9 @@ function renderComponents(components, total) { settingsHtml = settings.join(' | '); } - html += ''; + html += ''; html += ''; html += ''; - html += ''; html += ''; html += ''; html += ''; diff --git a/web/templates/pricelist_detail.html b/web/templates/pricelist_detail.html index da2fded..73fc38e 100644 --- a/web/templates/pricelist_detail.html +++ b/web/templates/pricelist_detail.html @@ -2,13 +2,21 @@ {{define "content"}}
-
- - - +
+
+ + + + + +

Загрузка...

+
+
@@ -305,6 +313,40 @@ }, 300); }); + async function exportToCSV() { + try { + const resp = await fetch(`/api/pricelists/${pricelistId}/export-csv`); + if (!resp.ok) { + throw new Error('Ошибка экспорта'); + } + + const blob = await resp.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + + // Get filename from Content-Disposition header or use default + const contentDisposition = resp.headers.get('Content-Disposition'); + let filename = `pricelist_${pricelistId}.csv`; + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + if (filenameMatch && filenameMatch[1]) { + filename = filenameMatch[1].replace(/['"]/g, ''); + } + } + + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + showToast('CSV файл экспортирован', 'success'); + } catch (e) { + showToast('Ошибка экспорта: ' + e.message, 'error'); + } + } + document.addEventListener('DOMContentLoaded', function() { loadPricelistInfo(); loadItems(1);
АртикулКатегорияОписаниеПопулярностьКол-во котировокЦена
' + escapeHtml(c.lot_name) + '' + escapeHtml(category) + '' + escapeHtml(desc) + '' + popularity + '' + quoteCount + '' + price + '