From 55acbe138bbed7ecb439844a31596f70331cbef4 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 19 May 2026 12:37:47 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D1=83=D0=BD=D0=B8=D1=84=D0=B8?= =?UTF-8?q?=D1=86=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20CSV-=D1=8D?= =?UTF-8?q?=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82,=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D1=81=D1=82=D0=B8=20pricing=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Вынести sortConfigsByLine() — устранить дублирование sort.Slice в ProjectToExportData и ProjectToPricingExportData - Добавить ConfigToPricingExportData() и ExportConfigPricingCSV handler - Зарегистрировать POST /api/configs/:uuid/export/pricing - Заменить клиентский DOM-скрапинг exportPricingCSV() на fetch к новому endpoint; артикул теперь включается через pricingConfigSummaryRow Co-Authored-By: Claude Sonnet 4.6 --- cmd/qfs/main.go | 1 + internal/handlers/export.go | 57 +++++++++++++++++++++++ internal/services/export.go | 68 +++++++++++++-------------- web/templates/index.html | 93 +++++++++++-------------------------- 4 files changed, 117 insertions(+), 102 deletions(-) diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index d8602b6..51f54a7 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1270,6 +1270,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect }) configs.GET("/:uuid/export", exportHandler.ExportConfigCSV) + configs.POST("/:uuid/export/pricing", exportHandler.ExportConfigPricingCSV) // Vendor spec (BOM) endpoints configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec) diff --git a/internal/handlers/export.go b/internal/handlers/export.go index 58c35e3..63ba70f 100644 --- a/internal/handlers/export.go +++ b/internal/handlers/export.go @@ -223,6 +223,63 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) { } } +func (h *ExportHandler) ExportConfigPricingCSV(c *gin.Context) { + uuid := c.Param("uuid") + + var req ProjectExportOptionsRequest + if err := c.ShouldBindJSON(&req); err != nil { + RespondError(c, http.StatusBadRequest, "invalid request", err) + return + } + + config, err := h.configService.GetByUUID(uuid, h.dbUsername) + if err != nil { + RespondError(c, http.StatusNotFound, "resource not found", err) + return + } + + opts := services.ProjectPricingExportOptions{ + IncludeLOT: req.IncludeLOT, + IncludeBOM: req.IncludeBOM, + IncludeEstimate: req.IncludeEstimate, + IncludeStock: req.IncludeStock, + IncludeCompetitor: req.IncludeCompetitor, + Basis: req.Basis, + SaleMarkup: req.SaleMarkup, + } + + data, err := h.exportService.ConfigToPricingExportData(config, opts) + if err != nil { + RespondError(c, http.StatusInternalServerError, "internal server error", err) + return + } + + basisLabel := "FOB" + if strings.EqualFold(strings.TrimSpace(req.Basis), "ddp") { + basisLabel = "DDP" + } + + projectCode := config.Name + if config.ProjectUUID != nil && *config.ProjectUUID != "" { + if project, err := h.projectService.GetByUUID(*config.ProjectUUID, h.dbUsername); err == nil && project != nil { + projectCode = project.Code + } + } + + filename := fmt.Sprintf("%s (%s) %s %s SPEC.csv", + time.Now().Format("2006-01-02"), + projectCode, + config.Name, + basisLabel, + ) + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + if err := h.exportService.ToPricingCSV(c.Writer, data, opts); err != nil { + c.Error(err) + } +} + func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) { projectUUID := c.Param("uuid") diff --git a/internal/services/export.go b/internal/services/export.go index a8b8b37..f812023 100644 --- a/internal/services/export.go +++ b/internal/services/export.go @@ -213,27 +213,30 @@ func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) { return buf.Bytes(), nil } -func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) { - sortedConfigs := make([]models.Configuration, len(configs)) - copy(sortedConfigs, configs) - sort.Slice(sortedConfigs, func(i, j int) bool { - leftLine := sortedConfigs[i].Line - rightLine := sortedConfigs[j].Line - - if leftLine <= 0 { - leftLine = int(^uint(0) >> 1) +func sortConfigsByLine(configs []models.Configuration) []models.Configuration { + sorted := make([]models.Configuration, len(configs)) + copy(sorted, configs) + sort.Slice(sorted, func(i, j int) bool { + li, lj := sorted[i].Line, sorted[j].Line + if li <= 0 { + li = int(^uint(0) >> 1) } - if rightLine <= 0 { - rightLine = int(^uint(0) >> 1) + if lj <= 0 { + lj = int(^uint(0) >> 1) } - if leftLine != rightLine { - return leftLine < rightLine + if li != lj { + return li < lj } - if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) { - return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt) + if !sorted[i].CreatedAt.Equal(sorted[j].CreatedAt) { + return sorted[i].CreatedAt.After(sorted[j].CreatedAt) } - return sortedConfigs[i].UUID > sortedConfigs[j].UUID + return sorted[i].UUID > sorted[j].UUID }) + return sorted +} + +func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) { + sortedConfigs := sortConfigsByLine(configs) blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs)) for i := range sortedConfigs { @@ -296,26 +299,7 @@ func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectEx // ProjectToExportData converts multiple configurations into ProjectExportData. func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData { - sortedConfigs := make([]models.Configuration, len(configs)) - copy(sortedConfigs, configs) - sort.Slice(sortedConfigs, func(i, j int) bool { - leftLine := sortedConfigs[i].Line - rightLine := sortedConfigs[j].Line - - if leftLine <= 0 { - leftLine = int(^uint(0) >> 1) - } - if rightLine <= 0 { - rightLine = int(^uint(0) >> 1) - } - if leftLine != rightLine { - return leftLine < rightLine - } - if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) { - return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt) - } - return sortedConfigs[i].UUID > sortedConfigs[j].UUID - }) + sortedConfigs := sortConfigsByLine(configs) blocks := make([]ConfigExportBlock, 0, len(configs)) for i := range sortedConfigs { @@ -327,6 +311,18 @@ func (s *ExportService) ProjectToExportData(configs []models.Configuration) *Pro } } +// ConfigToPricingExportData is a single-config variant of ProjectToPricingExportData. +func (s *ExportService) ConfigToPricingExportData(cfg *models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) { + block, err := s.buildPricingExportBlock(cfg, opts) + if err != nil { + return nil, err + } + return &ProjectPricingExportData{ + Configs: []ProjectPricingExportConfig{block}, + CreatedAt: time.Now(), + }, nil +} + func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock { // Batch-fetch categories from local data (pricelist items → local_components fallback) lotNames := make([]string, len(cfg.Items)) diff --git a/web/templates/index.html b/web/templates/index.html index 55261af..3094c13 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -4316,72 +4316,33 @@ function setPricingCustomPriceFromVendor() { } } -function exportPricingCSV(table) { - const bodyId = table === 'sale' ? 'pricing-body-sale' : 'pricing-body-buy'; - const rowClass = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy'; - const totalIds = table === 'sale' - ? { est: 'pricing-total-sale-estimate', wh: 'pricing-total-sale-warehouse', comp: 'pricing-total-sale-competitor', vendor: 'pricing-total-sale-vendor' } - : { est: 'pricing-total-buy-estimate', wh: 'pricing-total-buy-warehouse', comp: 'pricing-total-buy-competitor', vendor: 'pricing-total-buy-vendor' }; - - const rows = document.querySelectorAll(`#${bodyId} tr.${rowClass}`); - if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; } - - const csvDelimiter = ';'; - const cleanExportCell = value => { - const text = String(value || '').replace(/\s+/g, ' ').trim(); - if (!text || text === '—') return text || ''; - return text - .replace(/\s*\(.*\)$/, '') - .replace(/\s*\*+\s*$/, '') - .trim(); - }; - const csvEscape = v => { - if (v == null) return ''; - const s = String(v).replace(/"/g, '""'); - return /[;"\n\r]/.test(s) ? `"${s}"` : s; - }; - - const headers = ['PN вендора', 'Описание', 'LOT', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена']; - const lines = [headers.map(csvEscape).join(csvDelimiter)]; - - rows.forEach(tr => { - // PN вендора, Описание, LOT are stored in dataset to handle rowspan correctly - const pn = cleanExportCell(tr.dataset.vendorPn || ''); - const desc = cleanExportCell(tr.dataset.desc || ''); - const lot = cleanExportCell(tr.dataset.lot || ''); - // Qty..Ручная цена: cells at offset 2 for group-start rows, offset 0 for sub-rows - const isGroupStart = tr.dataset.groupStart === 'true'; - const cells = tr.querySelectorAll('td'); - const o = isGroupStart ? 2 : 0; - const cols = [pn, desc, lot, - cleanExportCell(cells[o]?.textContent), - cleanExportCell(cells[o+1]?.textContent), - cleanExportCell(cells[o+2]?.textContent), - cleanExportCell(cells[o+3]?.textContent), - cleanExportCell(cells[o+4]?.textContent), - ]; - lines.push(cols.map(csvEscape).join(csvDelimiter)); - }); - - // Totals row - const tEst = cleanExportCell(document.getElementById(totalIds.est)?.textContent); - const tWh = cleanExportCell(document.getElementById(totalIds.wh)?.textContent); - const tComp = cleanExportCell(document.getElementById(totalIds.comp)?.textContent); - const tVendor = cleanExportCell(document.getElementById(totalIds.vendor)?.textContent); - lines.push(['', '', '', 'Итого:', tEst, tWh, tComp, tVendor].map(csvEscape).join(csvDelimiter)); - - const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'}); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - const today = new Date(); - const datePart = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`; - const codePart = (projectCode || 'NO-PROJECT').trim(); - const namePart = (configName || 'config').trim(); - const suffix = table === 'sale' ? 'SALE' : 'BUY'; - a.download = `${datePart} (${codePart}) ${namePart} SPEC-${suffix}.csv`; - a.click(); - URL.revokeObjectURL(url); +async function exportPricingCSV(table) { + if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; } + const basis = table === 'sale' ? 'ddp' : 'fob'; + try { + const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + include_lot: true, + include_bom: true, + include_estimate: true, + include_stock: true, + include_competitor: true, + basis: basis, + }), + }); + if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; } + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = getFilenameFromResponse(resp) || `${configName || 'config'} SPEC-${basis.toUpperCase()}.csv`; + a.click(); + URL.revokeObjectURL(url); + } catch(e) { + showToast('Ошибка экспорта', 'error'); + } } function escapeHtml(str) {