package handlers import ( "fmt" "net/http" "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/services" "github.com/gin-gonic/gin" ) type ExportHandler struct { exportService *services.ExportService configService services.ConfigurationGetter projectService *services.ProjectService dbUsername string } func NewExportHandler( exportService *services.ExportService, configService services.ConfigurationGetter, projectService *services.ProjectService, dbUsername string, ) *ExportHandler { return &ExportHandler{ exportService: exportService, configService: configService, projectService: projectService, dbUsername: dbUsername, } } type ExportRequest struct { Name string `json:"name" binding:"required"` ProjectName string `json:"project_name"` ProjectUUID string `json:"project_uuid"` Article string `json:"article"` ServerCount int `json:"server_count"` PricelistID *uint `json:"pricelist_id"` Items []struct { LotName string `json:"lot_name" binding:"required"` Quantity int `json:"quantity" binding:"required,min=1"` UnitPrice float64 `json:"unit_price"` } `json:"items" binding:"required,min=1"` Notes string `json:"notes"` } type ProjectExportOptionsRequest struct { IncludeLOT bool `json:"include_lot"` IncludeBOM bool `json:"include_bom"` IncludeEstimate bool `json:"include_estimate"` IncludeStock bool `json:"include_stock"` IncludeCompetitor bool `json:"include_competitor"` } func (h *ExportHandler) ExportCSV(c *gin.Context) { var req ExportRequest if err := c.ShouldBindJSON(&req); err != nil { RespondError(c, http.StatusBadRequest, "invalid request", err) return } data := h.buildExportData(&req) // Validate before streaming (can return JSON error) if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"}) return } // Get project code for filename projectCode := req.ProjectName // legacy field: may contain code from frontend if projectCode == "" && req.ProjectUUID != "" { if project, err := h.projectService.GetByUUID(req.ProjectUUID, h.dbUsername); err == nil && project != nil { projectCode = project.Code } } if projectCode == "" { projectCode = req.Name } // Set headers before streaming exportDate := data.CreatedAt articleSegment := sanitizeFilenameSegment(req.Article) if articleSegment == "" { articleSegment = "BOM" } filename := fmt.Sprintf("%s (%s) %s %s.csv", exportDate.Format("2006-01-02"), projectCode, req.Name, articleSegment) c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) // Stream CSV (cannot return JSON after this point) if err := h.exportService.ToCSV(c.Writer, data); err != nil { c.Error(err) // Log only return } } // buildExportData converts an ExportRequest into a ProjectExportData using a temporary Configuration model // so that ExportService.ConfigToExportData can resolve categories via localDB. func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ProjectExportData { configItems := make(models.ConfigItems, len(req.Items)) for i, item := range req.Items { configItems[i] = models.ConfigItem{ LotName: item.LotName, Quantity: item.Quantity, UnitPrice: item.UnitPrice, } } serverCount := req.ServerCount if serverCount < 1 { serverCount = 1 } cfg := &models.Configuration{ Article: req.Article, ServerCount: serverCount, PricelistID: req.PricelistID, Items: configItems, CreatedAt: time.Now(), } return h.exportService.ConfigToExportData(cfg) } func sanitizeFilenameSegment(value string) string { if strings.TrimSpace(value) == "" { return "" } replacer := strings.NewReplacer( "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_", ) return strings.TrimSpace(replacer.Replace(value)) } func (h *ExportHandler) ExportConfigCSV(c *gin.Context) { uuid := c.Param("uuid") // Get config before streaming (can return JSON error) config, err := h.configService.GetByUUID(uuid, h.dbUsername) if err != nil { RespondError(c, http.StatusNotFound, "resource not found", err) return } data := h.exportService.ConfigToExportData(config) // Validate before streaming (can return JSON error) if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"}) return } // Get project code for filename projectCode := config.Name // fallback: use config name if no project if config.ProjectUUID != nil && *config.ProjectUUID != "" { if project, err := h.projectService.GetByUUID(*config.ProjectUUID, h.dbUsername); err == nil && project != nil { projectCode = project.Code } } // Set headers before streaming // Use price update time if available, otherwise creation time exportDate := config.CreatedAt if config.PriceUpdatedAt != nil { exportDate = *config.PriceUpdatedAt } filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectCode, config.Name) c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) // Stream CSV (cannot return JSON after this point) if err := h.exportService.ToCSV(c.Writer, data); err != nil { c.Error(err) // Log only return } } // ExportProjectCSV exports all active configurations of a project as a single CSV file. func (h *ExportHandler) ExportProjectCSV(c *gin.Context) { projectUUID := c.Param("uuid") project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername) if err != nil { RespondError(c, http.StatusNotFound, "resource not found", err) return } result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active") if err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) return } if len(result.Configs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"}) return } data := h.exportService.ProjectToExportData(result.Configs) // Filename: YYYY-MM-DD (ProjectCode) BOM.csv filename := fmt.Sprintf("%s (%s) BOM.csv", time.Now().Format("2006-01-02"), project.Code) c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) if err := h.exportService.ToCSV(c.Writer, data); err != nil { c.Error(err) return } } func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) { projectUUID := c.Param("uuid") var req ProjectExportOptionsRequest if err := c.ShouldBindJSON(&req); err != nil { RespondError(c, http.StatusBadRequest, "invalid request", err) return } project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername) if err != nil { RespondError(c, http.StatusNotFound, "resource not found", err) return } result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active") if err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) return } if len(result.Configs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"}) return } opts := services.ProjectPricingExportOptions{ IncludeLOT: req.IncludeLOT, IncludeBOM: req.IncludeBOM, IncludeEstimate: req.IncludeEstimate, IncludeStock: req.IncludeStock, IncludeCompetitor: req.IncludeCompetitor, } data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts) if err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) return } filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code) 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) return } }