chore: save current changes

This commit is contained in:
2026-02-18 07:02:17 +03:00
parent 2e973b6d78
commit 71f73e2f1d
10 changed files with 822 additions and 374 deletions

View File

@@ -7,37 +7,37 @@ import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"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
componentService *services.ComponentService
projectService *services.ProjectService
exportService *services.ExportService
configService services.ConfigurationGetter
projectService *services.ProjectService
}
func NewExportHandler(
exportService *services.ExportService,
configService services.ConfigurationGetter,
componentService *services.ComponentService,
projectService *services.ProjectService,
) *ExportHandler {
return &ExportHandler{
exportService: exportService,
configService: configService,
componentService: componentService,
projectService: projectService,
exportService: exportService,
configService: configService,
projectService: projectService,
}
}
type ExportRequest struct {
Name string `json:"name" binding:"required"`
ProjectName string `json:"project_name"`
ProjectUUID string `json:"project_uuid"`
Article string `json:"article"`
Items []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"`
@@ -55,22 +55,21 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
data := h.buildExportData(&req)
// Validate before streaming (can return JSON error)
if len(data.Items) == 0 {
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 name if available
projectName := req.ProjectName
if projectName == "" && req.ProjectUUID != "" {
// Try to load project name from database
// Get project code for filename
projectCode := req.ProjectName // legacy field: may contain code from frontend
if projectCode == "" && req.ProjectUUID != "" {
username := middleware.GetUsername(c)
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
projectName = derefString(project.Name)
projectCode = project.Code
}
}
if projectName == "" {
projectName = req.Name
if projectCode == "" {
projectCode = req.Name
}
// Set headers before streaming
@@ -79,7 +78,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
if articleSegment == "" {
articleSegment = "BOM"
}
filename := fmt.Sprintf("%s (%s) %s %s.csv", exportDate.Format("2006-01-02"), projectName, req.Name, articleSegment)
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))
@@ -90,51 +89,32 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
}
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
items := make([]services.ExportItem, len(req.Items))
var total float64
// 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 {
itemTotal := item.UnitPrice * float64(item.Quantity)
// Получаем информацию о компоненте для заполнения категории и описания
componentView, err := h.componentService.GetByLotName(item.LotName)
if err != nil {
// Если не удалось получить информацию о компоненте, используем только основные данные
items[i] = services.ExportItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
TotalPrice: itemTotal,
}
} else {
items[i] = services.ExportItem{
LotName: item.LotName,
Description: componentView.Description,
Category: componentView.Category,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
TotalPrice: itemTotal,
}
configItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
total += itemTotal
}
return &services.ExportData{
Name: req.Name,
Article: req.Article,
Items: items,
Total: total,
Notes: req.Notes,
CreatedAt: time.Now(),
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 {
@@ -166,19 +146,19 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
return
}
data := h.exportService.ConfigToExportData(config, h.componentService)
data := h.exportService.ConfigToExportData(config)
// Validate before streaming (can return JSON error)
if len(data.Items) == 0 {
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 name if configuration belongs to a project
projectName := config.Name // fallback: use config name if no project
// 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, username); err == nil && project != nil {
projectName = derefString(project.Name)
projectCode = project.Code
}
}
@@ -188,7 +168,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
if config.PriceUpdatedAt != nil {
exportDate = *config.PriceUpdatedAt
}
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, config.Name)
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))
@@ -198,3 +178,38 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
return
}
}
// ExportProjectCSV exports all active configurations of a project as a single CSV file.
func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
username := middleware.GetUsername(c)
projectUUID := c.Param("uuid")
project, err := h.projectService.GetByUUID(projectUUID, username)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
result, err := h.projectService.ListConfigurations(projectUUID, username, "active")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
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
}
}

View File

@@ -30,15 +30,11 @@ func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*model
func TestExportCSV_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
// Create a basic mock component service that doesn't panic
mockComponentService := &services.ComponentService{}
// Create handler with mocks
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{},
mockComponentService,
nil,
)
@@ -88,7 +84,7 @@ func TestExportCSV_Success(t *testing.T) {
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
actualBOM := responseBody[:3]
if bytes.Compare(actualBOM, expectedBOM) != 0 {
if !bytes.Equal(actualBOM, expectedBOM) {
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
}
@@ -101,19 +97,18 @@ func TestExportCSV_Success(t *testing.T) {
t.Errorf("Failed to parse CSV header: %v", err)
}
if len(header) != 6 {
t.Errorf("Expected 6 columns, got %d", len(header))
if len(header) != 8 {
t.Errorf("Expected 8 columns, got %d", len(header))
}
}
func TestExportCSV_InvalidRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{},
&services.ComponentService{},
nil,
)
@@ -143,11 +138,10 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
func TestExportCSV_EmptyItems(t *testing.T) {
gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{},
&services.ComponentService{},
nil,
)
@@ -185,11 +179,10 @@ func TestExportConfigCSV_Success(t *testing.T) {
CreatedAt: time.Now(),
}
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{config: mockConfig},
&services.ComponentService{},
nil,
)
@@ -227,7 +220,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
actualBOM := responseBody[:3]
if bytes.Compare(actualBOM, expectedBOM) != 0 {
if !bytes.Equal(actualBOM, expectedBOM) {
t.Errorf("UTF-8 BOM mismatch")
}
}
@@ -235,11 +228,10 @@ func TestExportConfigCSV_Success(t *testing.T) {
func TestExportConfigCSV_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{err: errors.New("config not found")},
&services.ComponentService{},
nil,
)
@@ -280,11 +272,10 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
CreatedAt: time.Now(),
}
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
handler := NewExportHandler(
exportSvc,
&mockConfigService{config: mockConfig},
&services.ComponentService{},
nil,
)