refactor: унифицировать CSV-экспорт, перенести pricing на сервер
- Вынести 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1270,6 +1270,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
})
|
})
|
||||||
|
|
||||||
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||||
|
configs.POST("/:uuid/export/pricing", exportHandler.ExportConfigPricingCSV)
|
||||||
|
|
||||||
// Vendor spec (BOM) endpoints
|
// Vendor spec (BOM) endpoints
|
||||||
configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec)
|
configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec)
|
||||||
|
|||||||
@@ -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) {
|
func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||||
projectUUID := c.Param("uuid")
|
projectUUID := c.Param("uuid")
|
||||||
|
|
||||||
|
|||||||
@@ -213,27 +213,30 @@ func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
|
|||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
|
func sortConfigsByLine(configs []models.Configuration) []models.Configuration {
|
||||||
sortedConfigs := make([]models.Configuration, len(configs))
|
sorted := make([]models.Configuration, len(configs))
|
||||||
copy(sortedConfigs, configs)
|
copy(sorted, configs)
|
||||||
sort.Slice(sortedConfigs, func(i, j int) bool {
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
leftLine := sortedConfigs[i].Line
|
li, lj := sorted[i].Line, sorted[j].Line
|
||||||
rightLine := sortedConfigs[j].Line
|
if li <= 0 {
|
||||||
|
li = int(^uint(0) >> 1)
|
||||||
if leftLine <= 0 {
|
|
||||||
leftLine = int(^uint(0) >> 1)
|
|
||||||
}
|
}
|
||||||
if rightLine <= 0 {
|
if lj <= 0 {
|
||||||
rightLine = int(^uint(0) >> 1)
|
lj = int(^uint(0) >> 1)
|
||||||
}
|
}
|
||||||
if leftLine != rightLine {
|
if li != lj {
|
||||||
return leftLine < rightLine
|
return li < lj
|
||||||
}
|
}
|
||||||
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
if !sorted[i].CreatedAt.Equal(sorted[j].CreatedAt) {
|
||||||
return sortedConfigs[i].CreatedAt.After(sortedConfigs[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))
|
blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs))
|
||||||
for i := range sortedConfigs {
|
for i := range sortedConfigs {
|
||||||
@@ -296,26 +299,7 @@ func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectEx
|
|||||||
|
|
||||||
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
||||||
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
||||||
sortedConfigs := make([]models.Configuration, len(configs))
|
sortedConfigs := sortConfigsByLine(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
|
|
||||||
})
|
|
||||||
|
|
||||||
blocks := make([]ConfigExportBlock, 0, len(configs))
|
blocks := make([]ConfigExportBlock, 0, len(configs))
|
||||||
for i := range sortedConfigs {
|
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 {
|
func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock {
|
||||||
// Batch-fetch categories from local data (pricelist items → local_components fallback)
|
// Batch-fetch categories from local data (pricelist items → local_components fallback)
|
||||||
lotNames := make([]string, len(cfg.Items))
|
lotNames := make([]string, len(cfg.Items))
|
||||||
|
|||||||
@@ -4316,72 +4316,33 @@ function setPricingCustomPriceFromVendor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportPricingCSV(table) {
|
async function exportPricingCSV(table) {
|
||||||
const bodyId = table === 'sale' ? 'pricing-body-sale' : 'pricing-body-buy';
|
if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; }
|
||||||
const rowClass = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
|
const basis = table === 'sale' ? 'ddp' : 'fob';
|
||||||
const totalIds = table === 'sale'
|
try {
|
||||||
? { est: 'pricing-total-sale-estimate', wh: 'pricing-total-sale-warehouse', comp: 'pricing-total-sale-competitor', vendor: 'pricing-total-sale-vendor' }
|
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
|
||||||
: { est: 'pricing-total-buy-estimate', wh: 'pricing-total-buy-warehouse', comp: 'pricing-total-buy-competitor', vendor: 'pricing-total-buy-vendor' };
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
const rows = document.querySelectorAll(`#${bodyId} tr.${rowClass}`);
|
body: JSON.stringify({
|
||||||
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
|
include_lot: true,
|
||||||
|
include_bom: true,
|
||||||
const csvDelimiter = ';';
|
include_estimate: true,
|
||||||
const cleanExportCell = value => {
|
include_stock: true,
|
||||||
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
include_competitor: true,
|
||||||
if (!text || text === '—') return text || '';
|
basis: basis,
|
||||||
return text
|
}),
|
||||||
.replace(/\s*\(.*\)$/, '')
|
});
|
||||||
.replace(/\s*\*+\s*$/, '')
|
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }
|
||||||
.trim();
|
const blob = await resp.blob();
|
||||||
};
|
const url = URL.createObjectURL(blob);
|
||||||
const csvEscape = v => {
|
const a = document.createElement('a');
|
||||||
if (v == null) return '';
|
a.href = url;
|
||||||
const s = String(v).replace(/"/g, '""');
|
a.download = getFilenameFromResponse(resp) || `${configName || 'config'} SPEC-${basis.toUpperCase()}.csv`;
|
||||||
return /[;"\n\r]/.test(s) ? `"${s}"` : s;
|
a.click();
|
||||||
};
|
URL.revokeObjectURL(url);
|
||||||
|
} catch(e) {
|
||||||
const headers = ['PN вендора', 'Описание', 'LOT', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
|
showToast('Ошибка экспорта', 'error');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
|
|||||||
Reference in New Issue
Block a user