fix: standardize CSV export filename format to use project name

Unified export filename format across both ExportCSV and ExportConfigCSV:
- Format: YYYY-MM-DD (project_name) config_name BOM.csv
- Use PriceUpdatedAt if available, otherwise CreatedAt
- Extract project name from ProjectUUID for ExportCSV via projectService
- Pass project_uuid from frontend to backend in export request
- Add projectUUID and projectName state variables to track project context

This ensures consistent naming whether exporting from form or project view,
and uses most recent price update timestamp in filename.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-02-09 17:22:51 +03:00
parent 8fd27d11a7
commit 8f596cec68
4 changed files with 45 additions and 10 deletions

View File

@@ -695,7 +695,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Handlers // Handlers
componentHandler := handlers.NewComponentHandler(componentService, local) componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService, projectService)
pricelistHandler := handlers.NewPricelistHandler(local) pricelistHandler := handlers.NewPricelistHandler(local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil { if err != nil {

View File

@@ -14,24 +14,28 @@ type ExportHandler struct {
exportService *services.ExportService exportService *services.ExportService
configService services.ConfigurationGetter configService services.ConfigurationGetter
componentService *services.ComponentService componentService *services.ComponentService
projectService *services.ProjectService
} }
func NewExportHandler( func NewExportHandler(
exportService *services.ExportService, exportService *services.ExportService,
configService services.ConfigurationGetter, configService services.ConfigurationGetter,
componentService *services.ComponentService, componentService *services.ComponentService,
projectService *services.ProjectService,
) *ExportHandler { ) *ExportHandler {
return &ExportHandler{ return &ExportHandler{
exportService: exportService, exportService: exportService,
configService: configService, configService: configService,
componentService: componentService, componentService: componentService,
projectService: projectService,
} }
} }
type ExportRequest struct { type ExportRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
ProjectName string `json:"project_name"` ProjectName string `json:"project_name"`
Items []struct { ProjectUUID string `json:"project_uuid"`
Items []struct {
LotName string `json:"lot_name" binding:"required"` LotName string `json:"lot_name" binding:"required"`
Quantity int `json:"quantity" binding:"required,min=1"` Quantity int `json:"quantity" binding:"required,min=1"`
UnitPrice float64 `json:"unit_price"` UnitPrice float64 `json:"unit_price"`
@@ -54,12 +58,22 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
return return
} }
// Set headers before streaming // Get project name if available
projectName := req.ProjectName projectName := req.ProjectName
if projectName == "" && req.ProjectUUID != "" {
// Try to load project name from database
username := middleware.GetUsername(c)
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
projectName = project.Name
}
}
if projectName == "" { if projectName == "" {
projectName = req.Name projectName = req.Name
} }
filename := fmt.Sprintf("%s (%s) %s BOM.csv", time.Now().Format("2006-01-02"), projectName, req.Name)
// Set headers before streaming
exportDate := data.CreatedAt
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, req.Name)
c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
@@ -128,9 +142,21 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
return return
} }
// Get project name if configuration belongs to a project
projectName := config.Name // fallback: use config name if no project
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
projectName = project.Name
}
}
// Set headers before streaming // Set headers before streaming
// For config export, use config name for both project and quotation name // Use price update time if available, otherwise creation time
filename := fmt.Sprintf("%s (%s) %s BOM.csv", config.CreatedAt.Format("2006-01-02"), config.Name, config.Name) exportDate := config.CreatedAt
if config.PriceUpdatedAt != nil {
exportDate = *config.PriceUpdatedAt
}
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, config.Name)
c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))

View File

@@ -39,6 +39,7 @@ func TestExportCSV_Success(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{}, &mockConfigService{},
mockComponentService, mockComponentService,
nil,
) )
// Create JSON request body // Create JSON request body
@@ -113,6 +114,7 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{}, &mockConfigService{},
&services.ComponentService{}, &services.ComponentService{},
nil,
) )
// Create invalid request (missing required field) // Create invalid request (missing required field)
@@ -146,6 +148,7 @@ func TestExportCSV_EmptyItems(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{}, &mockConfigService{},
&services.ComponentService{}, &services.ComponentService{},
nil,
) )
// Create request with empty items array - should fail binding validation // Create request with empty items array - should fail binding validation
@@ -187,6 +190,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{config: mockConfig}, &mockConfigService{config: mockConfig},
&services.ComponentService{}, &services.ComponentService{},
nil,
) )
// Create HTTP request // Create HTTP request
@@ -236,6 +240,7 @@ func TestExportConfigCSV_NotFound(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{err: errors.New("config not found")}, &mockConfigService{err: errors.New("config not found")},
&services.ComponentService{}, &services.ComponentService{},
nil,
) )
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil) req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
@@ -280,6 +285,7 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{config: mockConfig}, &mockConfigService{config: mockConfig},
&services.ComponentService{}, &services.ComponentService{},
nil,
) )
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil) req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)

View File

@@ -326,6 +326,8 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
// State // State
let configUUID = '{{.ConfigUUID}}'; let configUUID = '{{.ConfigUUID}}';
let configName = ''; let configName = '';
let projectUUID = '';
let projectName = '';
let currentTab = 'base'; let currentTab = 'base';
let allComponents = []; let allComponents = [];
let cart = []; let cart = [];
@@ -609,6 +611,7 @@ document.addEventListener('DOMContentLoaded', async function() {
const config = await resp.json(); const config = await resp.json();
configName = config.name; configName = config.name;
projectUUID = config.project_uuid || '';
document.getElementById('config-name').textContent = config.name; document.getElementById('config-name').textContent = config.name;
document.getElementById('save-buttons').classList.remove('hidden'); document.getElementById('save-buttons').classList.remove('hidden');
@@ -1795,7 +1798,7 @@ async function exportCSV() {
const resp = await fetch('/api/export/csv', { const resp = await fetch('/api/export/csv', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: exportItems, name: configName}) body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID})
}); });
const blob = await resp.blob(); const blob = await resp.blob();
@@ -2048,7 +2051,7 @@ async function exportCSVWithCustomPrice() {
const resp = await fetch('/api/export/csv', { const resp = await fetch('/api/export/csv', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: adjustedCart, name: configName}) body: JSON.stringify({items: adjustedCart, name: configName, project_uuid: projectUUID})
}); });
const blob = await resp.blob(); const blob = await resp.blob();