chore: save current changes
This commit is contained in:
@@ -27,6 +27,7 @@
|
|||||||
- Runtime-конфиг читается из user state (`config.yaml`) или через `-config` / `QFS_CONFIG_PATH`; не хранить рабочий `config.yaml` в репозитории.
|
- Runtime-конфиг читается из user state (`config.yaml`) или через `-config` / `QFS_CONFIG_PATH`; не хранить рабочий `config.yaml` в репозитории.
|
||||||
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
|
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
|
||||||
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
|
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
|
||||||
|
- CSV-экспорт: имя файла должно содержать **код проекта** (`project.Code`), а не название (`project.Name`). Формат: `YYYY-MM-DD (КодПроекта) ИмяКонфигурации Артикул.csv`.
|
||||||
|
|
||||||
## Key SQLite Data
|
## Key SQLite Data
|
||||||
- `connection_settings`
|
- `connection_settings`
|
||||||
|
|||||||
@@ -696,12 +696,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if mariaDB != nil {
|
if mariaDB != nil {
|
||||||
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
|
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
|
||||||
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
exportService = services.NewExportService(cfg.Export, categoryRepo, local)
|
||||||
} else {
|
} else {
|
||||||
// In offline mode, we still need to create services that don't require DB.
|
// In offline mode, we still need to create services that don't require DB.
|
||||||
componentService = services.NewComponentService(nil, nil, nil)
|
componentService = services.NewComponentService(nil, nil, nil)
|
||||||
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
|
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
|
||||||
exportService = services.NewExportService(cfg.Export, nil)
|
exportService = services.NewExportService(cfg.Export, nil, local)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isOnline function for local-first architecture
|
// isOnline function for local-first architecture
|
||||||
@@ -810,7 +810,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, projectService)
|
exportHandler := handlers.NewExportHandler(exportService, configService, 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 {
|
||||||
@@ -1292,6 +1292,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
})
|
})
|
||||||
|
|
||||||
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
|
||||||
|
|
||||||
|
configs.PATCH("/:uuid/server-count", func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
var req struct {
|
||||||
|
ServerCount int `json:"server_count" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config, err := configService.UpdateServerCount(uuid, req.ServerCount)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
projects := api.Group("/projects")
|
projects := api.Group("/projects")
|
||||||
@@ -1644,6 +1661,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusCreated, config)
|
c.JSON(http.StatusCreated, config)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
projects.GET("/:uuid/export", exportHandler.ExportProjectCSV)
|
||||||
|
|
||||||
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
|
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -14,20 +15,17 @@ import (
|
|||||||
type ExportHandler struct {
|
type ExportHandler struct {
|
||||||
exportService *services.ExportService
|
exportService *services.ExportService
|
||||||
configService services.ConfigurationGetter
|
configService services.ConfigurationGetter
|
||||||
componentService *services.ComponentService
|
|
||||||
projectService *services.ProjectService
|
projectService *services.ProjectService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExportHandler(
|
func NewExportHandler(
|
||||||
exportService *services.ExportService,
|
exportService *services.ExportService,
|
||||||
configService services.ConfigurationGetter,
|
configService services.ConfigurationGetter,
|
||||||
componentService *services.ComponentService,
|
|
||||||
projectService *services.ProjectService,
|
projectService *services.ProjectService,
|
||||||
) *ExportHandler {
|
) *ExportHandler {
|
||||||
return &ExportHandler{
|
return &ExportHandler{
|
||||||
exportService: exportService,
|
exportService: exportService,
|
||||||
configService: configService,
|
configService: configService,
|
||||||
componentService: componentService,
|
|
||||||
projectService: projectService,
|
projectService: projectService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +35,8 @@ type ExportRequest struct {
|
|||||||
ProjectName string `json:"project_name"`
|
ProjectName string `json:"project_name"`
|
||||||
ProjectUUID string `json:"project_uuid"`
|
ProjectUUID string `json:"project_uuid"`
|
||||||
Article string `json:"article"`
|
Article string `json:"article"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
|
PricelistID *uint `json:"pricelist_id"`
|
||||||
Items []struct {
|
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"`
|
||||||
@@ -55,22 +55,21 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
data := h.buildExportData(&req)
|
data := h.buildExportData(&req)
|
||||||
|
|
||||||
// Validate before streaming (can return JSON error)
|
// 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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get project name if available
|
// Get project code for filename
|
||||||
projectName := req.ProjectName
|
projectCode := req.ProjectName // legacy field: may contain code from frontend
|
||||||
if projectName == "" && req.ProjectUUID != "" {
|
if projectCode == "" && req.ProjectUUID != "" {
|
||||||
// Try to load project name from database
|
|
||||||
username := middleware.GetUsername(c)
|
username := middleware.GetUsername(c)
|
||||||
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
||||||
projectName = derefString(project.Name)
|
projectCode = project.Code
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if projectName == "" {
|
if projectCode == "" {
|
||||||
projectName = req.Name
|
projectCode = req.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers before streaming
|
// Set headers before streaming
|
||||||
@@ -79,7 +78,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
if articleSegment == "" {
|
if articleSegment == "" {
|
||||||
articleSegment = "BOM"
|
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-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))
|
||||||
|
|
||||||
@@ -90,51 +89,32 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func derefString(value *string) string {
|
// buildExportData converts an ExportRequest into a ProjectExportData using a temporary Configuration model
|
||||||
if value == nil {
|
// so that ExportService.ConfigToExportData can resolve categories via localDB.
|
||||||
return ""
|
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ProjectExportData {
|
||||||
}
|
configItems := make(models.ConfigItems, len(req.Items))
|
||||||
return *value
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
|
||||||
items := make([]services.ExportItem, len(req.Items))
|
|
||||||
var total float64
|
|
||||||
|
|
||||||
for i, item := range req.Items {
|
for i, item := range req.Items {
|
||||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
configItems[i] = models.ConfigItem{
|
||||||
|
|
||||||
// Получаем информацию о компоненте для заполнения категории и описания
|
|
||||||
componentView, err := h.componentService.GetByLotName(item.LotName)
|
|
||||||
if err != nil {
|
|
||||||
// Если не удалось получить информацию о компоненте, используем только основные данные
|
|
||||||
items[i] = services.ExportItem{
|
|
||||||
LotName: item.LotName,
|
LotName: item.LotName,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
UnitPrice: item.UnitPrice,
|
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total += itemTotal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &services.ExportData{
|
serverCount := req.ServerCount
|
||||||
Name: req.Name,
|
if serverCount < 1 {
|
||||||
|
serverCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &models.Configuration{
|
||||||
Article: req.Article,
|
Article: req.Article,
|
||||||
Items: items,
|
ServerCount: serverCount,
|
||||||
Total: total,
|
PricelistID: req.PricelistID,
|
||||||
Notes: req.Notes,
|
Items: configItems,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return h.exportService.ConfigToExportData(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeFilenameSegment(value string) string {
|
func sanitizeFilenameSegment(value string) string {
|
||||||
@@ -166,19 +146,19 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := h.exportService.ConfigToExportData(config, h.componentService)
|
data := h.exportService.ConfigToExportData(config)
|
||||||
|
|
||||||
// Validate before streaming (can return JSON error)
|
// 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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get project name if configuration belongs to a project
|
// Get project code for filename
|
||||||
projectName := config.Name // fallback: use config name if no project
|
projectCode := config.Name // fallback: use config name if no project
|
||||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||||
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
|
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 {
|
if config.PriceUpdatedAt != nil {
|
||||||
exportDate = *config.PriceUpdatedAt
|
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-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))
|
||||||
|
|
||||||
@@ -198,3 +178,38 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
return
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,15 +30,11 @@ func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*model
|
|||||||
func TestExportCSV_Success(t *testing.T) {
|
func TestExportCSV_Success(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
// Create a basic mock component service that doesn't panic
|
|
||||||
mockComponentService := &services.ComponentService{}
|
|
||||||
|
|
||||||
// Create handler with mocks
|
// Create handler with mocks
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
mockComponentService,
|
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,7 +84,7 @@ func TestExportCSV_Success(t *testing.T) {
|
|||||||
|
|
||||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
actualBOM := responseBody[:3]
|
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)
|
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)
|
t.Errorf("Failed to parse CSV header: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(header) != 6 {
|
if len(header) != 8 {
|
||||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExportCSV_InvalidRequest(t *testing.T) {
|
func TestExportCSV_InvalidRequest(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
&services.ComponentService{},
|
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,11 +138,10 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
|
|||||||
func TestExportCSV_EmptyItems(t *testing.T) {
|
func TestExportCSV_EmptyItems(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
&services.ComponentService{},
|
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -185,11 +179,10 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{config: mockConfig},
|
&mockConfigService{config: mockConfig},
|
||||||
&services.ComponentService{},
|
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -227,7 +220,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
|||||||
|
|
||||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
actualBOM := responseBody[:3]
|
actualBOM := responseBody[:3]
|
||||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||||
t.Errorf("UTF-8 BOM mismatch")
|
t.Errorf("UTF-8 BOM mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,11 +228,10 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
|||||||
func TestExportConfigCSV_NotFound(t *testing.T) {
|
func TestExportConfigCSV_NotFound(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{err: errors.New("config not found")},
|
&mockConfigService{err: errors.New("config not found")},
|
||||||
&services.ComponentService{},
|
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -280,11 +272,10 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
exportSvc := services.NewExportService(config.ExportConfig{}, nil)
|
exportSvc := services.NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
handler := NewExportHandler(
|
handler := NewExportHandler(
|
||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{config: mockConfig},
|
&mockConfigService{config: mockConfig},
|
||||||
&services.ComponentService{},
|
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
)
|
)
|
||||||
@@ -16,24 +18,18 @@ import (
|
|||||||
type ExportService struct {
|
type ExportService struct {
|
||||||
config config.ExportConfig
|
config config.ExportConfig
|
||||||
categoryRepo *repository.CategoryRepository
|
categoryRepo *repository.CategoryRepository
|
||||||
|
localDB *localdb.LocalDB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository) *ExportService {
|
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService {
|
||||||
return &ExportService{
|
return &ExportService{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
categoryRepo: categoryRepo,
|
categoryRepo: categoryRepo,
|
||||||
|
localDB: local,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportData struct {
|
// ExportItem represents a single component in an export block.
|
||||||
Name string
|
|
||||||
Article string
|
|
||||||
Items []ExportItem
|
|
||||||
Total float64
|
|
||||||
Notes string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExportItem struct {
|
type ExportItem struct {
|
||||||
LotName string
|
LotName string
|
||||||
Description string
|
Description string
|
||||||
@@ -43,19 +39,43 @@ type ExportItem struct {
|
|||||||
TotalPrice float64
|
TotalPrice float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
|
// ConfigExportBlock represents one configuration (server) in the export.
|
||||||
|
type ConfigExportBlock struct {
|
||||||
|
Article string
|
||||||
|
ServerCount int
|
||||||
|
UnitPrice float64 // sum of component prices for one server
|
||||||
|
Items []ExportItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectExportData holds all configuration blocks for a project-level export.
|
||||||
|
type ProjectExportData struct {
|
||||||
|
Configs []ConfigExportBlock
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToCSV writes project export data in the new structured CSV format.
|
||||||
|
//
|
||||||
|
// Format:
|
||||||
|
//
|
||||||
|
// Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)
|
||||||
|
// 10;;DL380-ARTICLE;;;10;10470;104 700
|
||||||
|
// ;;MB_INTEL_...;;1;;2074,5;
|
||||||
|
// ...
|
||||||
|
// (empty row)
|
||||||
|
// 20;;DL380-ARTICLE-2;;;2;10470;20 940
|
||||||
|
// ...
|
||||||
|
func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
|
||||||
// Write UTF-8 BOM for Excel compatibility
|
// Write UTF-8 BOM for Excel compatibility
|
||||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||||
return fmt.Errorf("failed to write BOM: %w", err)
|
return fmt.Errorf("failed to write BOM: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
csvWriter := csv.NewWriter(w)
|
csvWriter := csv.NewWriter(w)
|
||||||
// Use semicolon as delimiter for Russian Excel locale
|
|
||||||
csvWriter.Comma = ';'
|
csvWriter.Comma = ';'
|
||||||
defer csvWriter.Flush()
|
defer csvWriter.Flush()
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
headers := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
headers := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
||||||
if err := csvWriter.Write(headers); err != nil {
|
if err := csvWriter.Write(headers); err != nil {
|
||||||
return fmt.Errorf("failed to write header: %w", err)
|
return fmt.Errorf("failed to write header: %w", err)
|
||||||
}
|
}
|
||||||
@@ -71,47 +91,59 @@ func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for i, block := range data.Configs {
|
||||||
|
lineNo := (i + 1) * 10
|
||||||
|
|
||||||
|
serverCount := block.ServerCount
|
||||||
|
if serverCount < 1 {
|
||||||
|
serverCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPrice := block.UnitPrice * float64(serverCount)
|
||||||
|
|
||||||
|
// Server summary row
|
||||||
|
serverRow := []string{
|
||||||
|
fmt.Sprintf("%d", lineNo), // Line
|
||||||
|
"", // Type
|
||||||
|
block.Article, // p/n
|
||||||
|
"", // Description
|
||||||
|
"", // Qty (1 pcs.)
|
||||||
|
fmt.Sprintf("%d", serverCount), // Qty (total)
|
||||||
|
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
|
||||||
|
formatPriceWithSpace(totalPrice), // Price (total)
|
||||||
|
}
|
||||||
|
if err := csvWriter.Write(serverRow); err != nil {
|
||||||
|
return fmt.Errorf("failed to write server row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Sort items by category display order
|
// Sort items by category display order
|
||||||
sortedItems := make([]ExportItem, len(data.Items))
|
sortedItems := make([]ExportItem, len(block.Items))
|
||||||
copy(sortedItems, data.Items)
|
copy(sortedItems, block.Items)
|
||||||
|
sortItemsByCategory(sortedItems, categoryOrder)
|
||||||
|
|
||||||
// Sort using category display order (items without category go to the end)
|
// Component rows
|
||||||
for i := 0; i < len(sortedItems)-1; i++ {
|
|
||||||
for j := i + 1; j < len(sortedItems); j++ {
|
|
||||||
orderI, hasI := categoryOrder[sortedItems[i].Category]
|
|
||||||
orderJ, hasJ := categoryOrder[sortedItems[j].Category]
|
|
||||||
|
|
||||||
// Items without category go to the end
|
|
||||||
if !hasI && hasJ {
|
|
||||||
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
|
||||||
} else if hasI && hasJ {
|
|
||||||
// Both have categories, sort by display order
|
|
||||||
if orderI > orderJ {
|
|
||||||
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Items
|
|
||||||
for _, item := range sortedItems {
|
for _, item := range sortedItems {
|
||||||
row := []string{
|
componentRow := []string{
|
||||||
item.LotName,
|
"", // Line
|
||||||
item.Description,
|
item.Category, // Type
|
||||||
item.Category,
|
item.LotName, // p/n
|
||||||
fmt.Sprintf("%d", item.Quantity),
|
"", // Description
|
||||||
strings.ReplaceAll(fmt.Sprintf("%.2f", item.UnitPrice), ".", ","),
|
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
|
||||||
strings.ReplaceAll(fmt.Sprintf("%.2f", item.TotalPrice), ".", ","),
|
"", // Qty (total)
|
||||||
|
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
|
||||||
|
"", // Price (total)
|
||||||
}
|
}
|
||||||
if err := csvWriter.Write(row); err != nil {
|
if err := csvWriter.Write(componentRow); err != nil {
|
||||||
return fmt.Errorf("failed to write row: %w", err)
|
return fmt.Errorf("failed to write component row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total row
|
// Empty separator row between blocks (skip after last)
|
||||||
totalStr := strings.ReplaceAll(fmt.Sprintf("%.2f", data.Total), ".", ",")
|
if i < len(data.Configs)-1 {
|
||||||
if err := csvWriter.Write([]string{data.Article, "", "", "", "ИТОГО:", totalStr}); err != nil {
|
if err := csvWriter.Write([]string{"", "", "", "", "", "", "", ""}); err != nil {
|
||||||
return fmt.Errorf("failed to write total row: %w", err)
|
return fmt.Errorf("failed to write separator row: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
csvWriter.Flush()
|
csvWriter.Flush()
|
||||||
@@ -122,8 +154,8 @@ func (s *ExportService) ToCSV(w io.Writer, data *ExportData) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes
|
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes.
|
||||||
func (s *ExportService) ToCSVBytes(data *ExportData) ([]byte, error) {
|
func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := s.ToCSV(&buf, data); err != nil {
|
if err := s.ToCSV(&buf, data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -131,42 +163,163 @@ func (s *ExportService) ToCSVBytes(data *ExportData) ([]byte, error) {
|
|||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
|
// ConfigToExportData converts a single configuration into ProjectExportData.
|
||||||
items := make([]ExportItem, len(config.Items))
|
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
|
||||||
var total float64
|
block := s.buildExportBlock(cfg)
|
||||||
|
return &ProjectExportData{
|
||||||
for i, item := range config.Items {
|
Configs: []ConfigExportBlock{block},
|
||||||
itemTotal := item.UnitPrice * float64(item.Quantity)
|
CreatedAt: cfg.CreatedAt,
|
||||||
|
|
||||||
// Получаем информацию о компоненте для заполнения категории
|
|
||||||
componentView, err := componentService.GetByLotName(item.LotName)
|
|
||||||
if err != nil {
|
|
||||||
// Если не удалось получить информацию о компоненте, используем только основные данные
|
|
||||||
items[i] = ExportItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: item.UnitPrice,
|
|
||||||
TotalPrice: itemTotal,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
items[i] = ExportItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Description: componentView.Description,
|
|
||||||
Category: componentView.Category,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: item.UnitPrice,
|
|
||||||
TotalPrice: itemTotal,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total += itemTotal
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ExportData{
|
|
||||||
Name: config.Name,
|
|
||||||
Article: "",
|
|
||||||
Items: items,
|
|
||||||
Total: total,
|
|
||||||
Notes: config.Notes,
|
|
||||||
CreatedAt: config.CreatedAt,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
||||||
|
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
||||||
|
blocks := make([]ConfigExportBlock, 0, len(configs))
|
||||||
|
for i := range configs {
|
||||||
|
blocks = append(blocks, s.buildExportBlock(&configs[i]))
|
||||||
|
}
|
||||||
|
return &ProjectExportData{
|
||||||
|
Configs: blocks,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
for i, item := range cfg.Items {
|
||||||
|
lotNames[i] = item.LotName
|
||||||
|
}
|
||||||
|
categories := s.resolveCategories(cfg.PricelistID, lotNames)
|
||||||
|
|
||||||
|
items := make([]ExportItem, len(cfg.Items))
|
||||||
|
var unitTotal float64
|
||||||
|
|
||||||
|
for i, item := range cfg.Items {
|
||||||
|
itemTotal := item.UnitPrice * float64(item.Quantity)
|
||||||
|
items[i] = ExportItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Category: categories[item.LotName],
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: itemTotal,
|
||||||
|
}
|
||||||
|
unitTotal += itemTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
serverCount := cfg.ServerCount
|
||||||
|
if serverCount < 1 {
|
||||||
|
serverCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigExportBlock{
|
||||||
|
Article: cfg.Article,
|
||||||
|
ServerCount: serverCount,
|
||||||
|
UnitPrice: unitTotal,
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCategories returns lot_name → category map.
|
||||||
|
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
||||||
|
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
||||||
|
if len(lotNames) == 0 || s.localDB == nil {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
categories := make(map[string]string, len(lotNames))
|
||||||
|
|
||||||
|
// Primary: pricelist items
|
||||||
|
if pricelistID != nil && *pricelistID > 0 {
|
||||||
|
if cats, err := s.localDB.GetLocalLotCategoriesByServerPricelistID(*pricelistID, lotNames); err == nil {
|
||||||
|
for lot, cat := range cats {
|
||||||
|
if strings.TrimSpace(cat) != "" {
|
||||||
|
categories[lot] = cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: local_components for any still missing
|
||||||
|
var missing []string
|
||||||
|
for _, lot := range lotNames {
|
||||||
|
if categories[lot] == "" {
|
||||||
|
missing = append(missing, lot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
if fallback, err := s.localDB.GetLocalComponentCategoriesByLotNames(missing); err == nil {
|
||||||
|
for lot, cat := range fallback {
|
||||||
|
if strings.TrimSpace(cat) != "" {
|
||||||
|
categories[lot] = cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortItemsByCategory sorts items by category display order (items without category go to the end).
|
||||||
|
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
||||||
|
for i := 0; i < len(items)-1; i++ {
|
||||||
|
for j := i + 1; j < len(items); j++ {
|
||||||
|
orderI, hasI := categoryOrder[items[i].Category]
|
||||||
|
orderJ, hasJ := categoryOrder[items[j].Category]
|
||||||
|
|
||||||
|
if !hasI && hasJ {
|
||||||
|
items[i], items[j] = items[j], items[i]
|
||||||
|
} else if hasI && hasJ && orderI > orderJ {
|
||||||
|
items[i], items[j] = items[j], items[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
|
||||||
|
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
|
||||||
|
func formatPriceComma(value float64) string {
|
||||||
|
if value == math.Trunc(value) {
|
||||||
|
return fmt.Sprintf("%.0f", value)
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf("%.2f", value)
|
||||||
|
s = strings.ReplaceAll(s, ".", ",")
|
||||||
|
// Trim trailing zero: "2074,50" -> "2074,5"
|
||||||
|
s = strings.TrimRight(s, "0")
|
||||||
|
s = strings.TrimRight(s, ",")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPriceInt formats price as integer (rounded), no decimal.
|
||||||
|
func formatPriceInt(value float64) string {
|
||||||
|
return fmt.Sprintf("%.0f", math.Round(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPriceWithSpace formats a price as an integer with space as thousands separator (e.g., "104 700").
|
||||||
|
func formatPriceWithSpace(value float64) string {
|
||||||
|
intVal := int64(math.Round(value))
|
||||||
|
if intVal < 0 {
|
||||||
|
return "-" + formatIntWithSpace(-intVal)
|
||||||
|
}
|
||||||
|
return formatIntWithSpace(intVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatIntWithSpace(n int64) string {
|
||||||
|
s := fmt.Sprintf("%d", n)
|
||||||
|
if len(s) <= 3 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
remainder := len(s) % 3
|
||||||
|
if remainder > 0 {
|
||||||
|
result.WriteString(s[:remainder])
|
||||||
|
}
|
||||||
|
for i := remainder; i < len(s); i += 3 {
|
||||||
|
if result.Len() > 0 {
|
||||||
|
result.WriteByte(' ')
|
||||||
|
}
|
||||||
|
result.WriteString(s[i : i+3])
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,25 +10,39 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData {
|
||||||
|
var unitTotal float64
|
||||||
|
for _, item := range items {
|
||||||
|
unitTotal += item.UnitPrice * float64(item.Quantity)
|
||||||
|
}
|
||||||
|
if serverCount < 1 {
|
||||||
|
serverCount = 1
|
||||||
|
}
|
||||||
|
return &ProjectExportData{
|
||||||
|
Configs: []ConfigExportBlock{
|
||||||
|
{
|
||||||
|
Article: article,
|
||||||
|
ServerCount: serverCount,
|
||||||
|
UnitPrice: unitTotal,
|
||||||
|
Items: items,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToCSV_UTF8BOM(t *testing.T) {
|
func TestToCSV_UTF8BOM(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
data := &ExportData{
|
data := newTestProjectData([]ExportItem{
|
||||||
Name: "Test",
|
|
||||||
Items: []ExportItem{
|
|
||||||
{
|
{
|
||||||
LotName: "LOT-001",
|
LotName: "LOT-001",
|
||||||
Description: "Test Item",
|
|
||||||
Category: "CAT",
|
Category: "CAT",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: 100.0,
|
UnitPrice: 100.0,
|
||||||
TotalPrice: 100.0,
|
TotalPrice: 100.0,
|
||||||
},
|
},
|
||||||
},
|
}, "TEST-ARTICLE", 1)
|
||||||
Total: 100.0,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := svc.ToCSV(&buf, data); err != nil {
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
@@ -40,40 +54,31 @@ func TestToCSV_UTF8BOM(t *testing.T) {
|
|||||||
t.Fatalf("CSV too short to contain BOM")
|
t.Fatalf("CSV too short to contain BOM")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check UTF-8 BOM: 0xEF 0xBB 0xBF
|
|
||||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
actualBOM := csvBytes[:3]
|
actualBOM := csvBytes[:3]
|
||||||
|
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
|
||||||
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
t.Errorf("UTF-8 BOM mismatch. Expected %v, got %v", expectedBOM, actualBOM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
data := &ExportData{
|
data := newTestProjectData([]ExportItem{
|
||||||
Name: "Test",
|
|
||||||
Items: []ExportItem{
|
|
||||||
{
|
{
|
||||||
LotName: "LOT-001",
|
LotName: "LOT-001",
|
||||||
Description: "Test Item",
|
|
||||||
Category: "CAT",
|
Category: "CAT",
|
||||||
Quantity: 2,
|
Quantity: 2,
|
||||||
UnitPrice: 100.50,
|
UnitPrice: 100.50,
|
||||||
TotalPrice: 201.00,
|
TotalPrice: 201.00,
|
||||||
},
|
},
|
||||||
},
|
}, "TEST-ARTICLE", 1)
|
||||||
Total: 201.00,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := svc.ToCSV(&buf, data); err != nil {
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
t.Fatalf("ToCSV failed: %v", err)
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip BOM and read CSV with semicolon delimiter
|
|
||||||
csvBytes := buf.Bytes()
|
csvBytes := buf.Bytes()
|
||||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
reader.Comma = ';'
|
reader.Comma = ';'
|
||||||
@@ -84,125 +89,52 @@ func TestToCSV_SemicolonDelimiter(t *testing.T) {
|
|||||||
t.Fatalf("Failed to read header: %v", err)
|
t.Fatalf("Failed to read header: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(header) != 6 {
|
if len(header) != 8 {
|
||||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedHeader := []string{"Артикул", "Описание", "Категория", "Количество", "Цена за единицу", "Сумма"}
|
expectedHeader := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
||||||
for i, col := range expectedHeader {
|
for i, col := range expectedHeader {
|
||||||
if i < len(header) && header[i] != col {
|
if i < len(header) && header[i] != col {
|
||||||
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
t.Errorf("Column %d: expected %q, got %q", i, col, header[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read item row
|
// Read server row
|
||||||
|
serverRow, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read server row: %v", err)
|
||||||
|
}
|
||||||
|
if serverRow[0] != "10" {
|
||||||
|
t.Errorf("Expected line number 10, got %s", serverRow[0])
|
||||||
|
}
|
||||||
|
if serverRow[2] != "TEST-ARTICLE" {
|
||||||
|
t.Errorf("Expected article TEST-ARTICLE, got %s", serverRow[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read component row
|
||||||
itemRow, err := reader.Read()
|
itemRow, err := reader.Read()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to read item row: %v", err)
|
t.Fatalf("Failed to read item row: %v", err)
|
||||||
}
|
}
|
||||||
|
if itemRow[2] != "LOT-001" {
|
||||||
if itemRow[0] != "LOT-001" {
|
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[2])
|
||||||
t.Errorf("Lot name mismatch: expected LOT-001, got %s", itemRow[0])
|
|
||||||
}
|
}
|
||||||
|
if itemRow[4] != "2" {
|
||||||
if itemRow[3] != "2" {
|
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[4])
|
||||||
t.Errorf("Quantity mismatch: expected 2, got %s", itemRow[3])
|
|
||||||
}
|
}
|
||||||
|
if itemRow[6] != "100,5" {
|
||||||
if itemRow[4] != "100,50" {
|
t.Errorf("Unit price mismatch: expected 100,5, got %s", itemRow[6])
|
||||||
t.Errorf("Unit price mismatch: expected 100,50, got %s", itemRow[4])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSV_TotalRow(t *testing.T) {
|
func TestToCSV_ServerRow(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
data := &ExportData{
|
data := newTestProjectData([]ExportItem{
|
||||||
Name: "Test",
|
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
Items: []ExportItem{
|
{LotName: "LOT-002", Category: "CAT", Quantity: 2, UnitPrice: 50.0, TotalPrice: 100.0},
|
||||||
{
|
}, "DL380-ART", 10)
|
||||||
LotName: "LOT-001",
|
|
||||||
Description: "Item 1",
|
|
||||||
Category: "CAT",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
LotName: "LOT-002",
|
|
||||||
Description: "Item 2",
|
|
||||||
Category: "CAT",
|
|
||||||
Quantity: 2,
|
|
||||||
UnitPrice: 50.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Total: 200.0,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := svc.ToCSV(&buf, data); err != nil {
|
|
||||||
t.Fatalf("ToCSV failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
csvBytes := buf.Bytes()
|
|
||||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
|
||||||
reader.Comma = ';'
|
|
||||||
|
|
||||||
// Skip header and item rows
|
|
||||||
reader.Read()
|
|
||||||
reader.Read()
|
|
||||||
reader.Read()
|
|
||||||
|
|
||||||
// Read total row
|
|
||||||
totalRow, err := reader.Read()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read total row: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Total row should have "ИТОГО:" in position 4 and total value in position 5
|
|
||||||
if totalRow[4] != "ИТОГО:" {
|
|
||||||
t.Errorf("Expected 'ИТОГО:' in column 4, got %q", totalRow[4])
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalRow[5] != "200,00" {
|
|
||||||
t.Errorf("Expected total 200,00, got %s", totalRow[5])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToCSV_CategorySorting(t *testing.T) {
|
|
||||||
// Test category sorting without category repo (items maintain original order)
|
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
|
||||||
|
|
||||||
data := &ExportData{
|
|
||||||
Name: "Test",
|
|
||||||
Items: []ExportItem{
|
|
||||||
{
|
|
||||||
LotName: "LOT-001",
|
|
||||||
Category: "CAT-A",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
LotName: "LOT-002",
|
|
||||||
Category: "CAT-C",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
LotName: "LOT-003",
|
|
||||||
Category: "CAT-B",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Total: 300.0,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := svc.ToCSV(&buf, data); err != nil {
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
@@ -216,30 +148,75 @@ func TestToCSV_CategorySorting(t *testing.T) {
|
|||||||
// Skip header
|
// Skip header
|
||||||
reader.Read()
|
reader.Read()
|
||||||
|
|
||||||
|
// Read server row
|
||||||
|
serverRow, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read server row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if serverRow[0] != "10" {
|
||||||
|
t.Errorf("Expected line 10, got %s", serverRow[0])
|
||||||
|
}
|
||||||
|
if serverRow[2] != "DL380-ART" {
|
||||||
|
t.Errorf("Expected article DL380-ART, got %s", serverRow[2])
|
||||||
|
}
|
||||||
|
if serverRow[5] != "10" {
|
||||||
|
t.Errorf("Expected server count 10, got %s", serverRow[5])
|
||||||
|
}
|
||||||
|
// UnitPrice = 100 + 100 = 200
|
||||||
|
if serverRow[6] != "200" {
|
||||||
|
t.Errorf("Expected unit price 200, got %s", serverRow[6])
|
||||||
|
}
|
||||||
|
// TotalPrice = 200 * 10 = 2000
|
||||||
|
if serverRow[7] != "2 000" {
|
||||||
|
t.Errorf("Expected total price '2 000', got %q", serverRow[7])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToCSV_CategorySorting(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
|
data := newTestProjectData([]ExportItem{
|
||||||
|
{LotName: "LOT-001", Category: "CAT-A", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
|
{LotName: "LOT-002", Category: "CAT-C", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
|
{LotName: "LOT-003", Category: "CAT-B", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
|
}, "ART", 1)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvBytes := buf.Bytes()
|
||||||
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
|
||||||
|
// Skip header and server row
|
||||||
|
reader.Read()
|
||||||
|
reader.Read()
|
||||||
|
|
||||||
// Without category repo, items maintain original order
|
// Without category repo, items maintain original order
|
||||||
row1, _ := reader.Read()
|
row1, _ := reader.Read()
|
||||||
if row1[0] != "LOT-001" {
|
if row1[2] != "LOT-001" {
|
||||||
t.Errorf("Expected LOT-001 first, got %s", row1[0])
|
t.Errorf("Expected LOT-001 first, got %s", row1[2])
|
||||||
}
|
}
|
||||||
|
|
||||||
row2, _ := reader.Read()
|
row2, _ := reader.Read()
|
||||||
if row2[0] != "LOT-002" {
|
if row2[2] != "LOT-002" {
|
||||||
t.Errorf("Expected LOT-002 second, got %s", row2[0])
|
t.Errorf("Expected LOT-002 second, got %s", row2[2])
|
||||||
}
|
}
|
||||||
|
|
||||||
row3, _ := reader.Read()
|
row3, _ := reader.Read()
|
||||||
if row3[0] != "LOT-003" {
|
if row3[2] != "LOT-003" {
|
||||||
t.Errorf("Expected LOT-003 third, got %s", row3[0])
|
t.Errorf("Expected LOT-003 third, got %s", row3[2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSV_EmptyData(t *testing.T) {
|
func TestToCSV_EmptyData(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
data := &ExportData{
|
data := &ProjectExportData{
|
||||||
Name: "Test",
|
Configs: []ConfigExportBlock{},
|
||||||
Items: []ExportItem{},
|
|
||||||
Total: 0.0,
|
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,44 +229,28 @@ func TestToCSV_EmptyData(t *testing.T) {
|
|||||||
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
reader.Comma = ';'
|
reader.Comma = ';'
|
||||||
|
|
||||||
// Should have header and total row
|
|
||||||
header, err := reader.Read()
|
header, err := reader.Read()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to read header: %v", err)
|
t.Fatalf("Failed to read header: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(header) != 6 {
|
if len(header) != 8 {
|
||||||
t.Errorf("Expected 6 columns, got %d", len(header))
|
t.Errorf("Expected 8 columns, got %d", len(header))
|
||||||
}
|
}
|
||||||
|
|
||||||
totalRow, err := reader.Read()
|
// No more rows expected
|
||||||
if err != nil {
|
_, err = reader.Read()
|
||||||
t.Fatalf("Failed to read total row: %v", err)
|
if err != io.EOF {
|
||||||
}
|
t.Errorf("Expected EOF after header, got: %v", err)
|
||||||
|
|
||||||
if totalRow[4] != "ИТОГО:" {
|
|
||||||
t.Errorf("Expected ИТОГО: in total row, got %s", totalRow[4])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
data := &ExportData{
|
data := newTestProjectData([]ExportItem{
|
||||||
Name: "Test",
|
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
Items: []ExportItem{
|
}, "ART", 1)
|
||||||
{
|
|
||||||
LotName: "LOT-001",
|
|
||||||
Description: "Test Item",
|
|
||||||
Category: "CAT",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Total: 100.0,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
csvBytes, err := svc.ToCSVBytes(data)
|
csvBytes, err := svc.ToCSVBytes(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -300,34 +261,20 @@ func TestToCSVBytes_BackwardCompat(t *testing.T) {
|
|||||||
t.Fatalf("CSV bytes too short")
|
t.Fatalf("CSV bytes too short")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify BOM is present
|
|
||||||
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
expectedBOM := []byte{0xEF, 0xBB, 0xBF}
|
||||||
actualBOM := csvBytes[:3]
|
actualBOM := csvBytes[:3]
|
||||||
if bytes.Compare(actualBOM, expectedBOM) != 0 {
|
if !bytes.Equal(actualBOM, expectedBOM) {
|
||||||
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
t.Errorf("UTF-8 BOM mismatch in ToCSVBytes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToCSV_WriterError(t *testing.T) {
|
func TestToCSV_WriterError(t *testing.T) {
|
||||||
svc := NewExportService(config.ExportConfig{}, nil)
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
data := &ExportData{
|
data := newTestProjectData([]ExportItem{
|
||||||
Name: "Test",
|
{LotName: "LOT-001", Category: "CAT", Quantity: 1, UnitPrice: 100.0, TotalPrice: 100.0},
|
||||||
Items: []ExportItem{
|
}, "ART", 1)
|
||||||
{
|
|
||||||
LotName: "LOT-001",
|
|
||||||
Description: "Test",
|
|
||||||
Category: "CAT",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: 100.0,
|
|
||||||
TotalPrice: 100.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Total: 100.0,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a failing writer
|
|
||||||
failingWriter := &failingWriter{}
|
failingWriter := &failingWriter{}
|
||||||
|
|
||||||
if err := svc.ToCSV(failingWriter, data); err == nil {
|
if err := svc.ToCSV(failingWriter, data); err == nil {
|
||||||
@@ -335,6 +282,122 @@ func TestToCSV_WriterError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToCSV_MultipleBlocks(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
|
||||||
|
data := &ProjectExportData{
|
||||||
|
Configs: []ConfigExportBlock{
|
||||||
|
{
|
||||||
|
Article: "ART-1",
|
||||||
|
ServerCount: 2,
|
||||||
|
UnitPrice: 500.0,
|
||||||
|
Items: []ExportItem{
|
||||||
|
{LotName: "LOT-A", Category: "CPU", Quantity: 1, UnitPrice: 500.0, TotalPrice: 500.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Article: "ART-2",
|
||||||
|
ServerCount: 3,
|
||||||
|
UnitPrice: 1000.0,
|
||||||
|
Items: []ExportItem{
|
||||||
|
{LotName: "LOT-B", Category: "MEM", Quantity: 2, UnitPrice: 500.0, TotalPrice: 1000.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ToCSV(&buf, data); err != nil {
|
||||||
|
t.Fatalf("ToCSV failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvBytes := buf.Bytes()
|
||||||
|
reader := csv.NewReader(bytes.NewReader(csvBytes[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
reader.FieldsPerRecord = -1 // allow variable fields
|
||||||
|
|
||||||
|
// Header
|
||||||
|
reader.Read()
|
||||||
|
|
||||||
|
// Block 1: server row
|
||||||
|
srv1, _ := reader.Read()
|
||||||
|
if srv1[0] != "10" {
|
||||||
|
t.Errorf("Block 1 line: expected 10, got %s", srv1[0])
|
||||||
|
}
|
||||||
|
if srv1[7] != "1 000" {
|
||||||
|
t.Errorf("Block 1 total: expected '1 000', got %q", srv1[7])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block 1: component row
|
||||||
|
comp1, _ := reader.Read()
|
||||||
|
if comp1[2] != "LOT-A" {
|
||||||
|
t.Errorf("Block 1 component: expected LOT-A, got %s", comp1[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator row
|
||||||
|
sep, _ := reader.Read()
|
||||||
|
allEmpty := true
|
||||||
|
for _, v := range sep {
|
||||||
|
if v != "" {
|
||||||
|
allEmpty = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allEmpty {
|
||||||
|
t.Errorf("Expected empty separator row, got %v", sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block 2: server row
|
||||||
|
srv2, _ := reader.Read()
|
||||||
|
if srv2[0] != "20" {
|
||||||
|
t.Errorf("Block 2 line: expected 20, got %s", srv2[0])
|
||||||
|
}
|
||||||
|
if srv2[7] != "3 000" {
|
||||||
|
t.Errorf("Block 2 total: expected '3 000', got %q", srv2[7])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatPriceWithSpace(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input float64
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{0, "0"},
|
||||||
|
{100, "100"},
|
||||||
|
{1000, "1 000"},
|
||||||
|
{10470, "10 470"},
|
||||||
|
{104700, "104 700"},
|
||||||
|
{1000000, "1 000 000"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := formatPriceWithSpace(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("formatPriceWithSpace(%v): expected %q, got %q", tt.input, tt.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatPriceComma(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input float64
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{100.0, "100"},
|
||||||
|
{2074.5, "2074,5"},
|
||||||
|
{100.50, "100,5"},
|
||||||
|
{99.99, "99,99"},
|
||||||
|
{0, "0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := formatPriceComma(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("formatPriceComma(%v): expected %q, got %q", tt.input, tt.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// failingWriter always returns an error
|
// failingWriter always returns an error
|
||||||
type failingWriter struct{}
|
type failingWriter struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -788,6 +788,58 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateServerCount updates server count and recalculates total price without creating a new version.
|
||||||
|
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
|
||||||
|
if serverCount < 1 {
|
||||||
|
return nil, fmt.Errorf("server count must be at least 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(configUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg.ServerCount = serverCount
|
||||||
|
total := localCfg.Items.Total()
|
||||||
|
if serverCount > 1 {
|
||||||
|
total *= float64(serverCount)
|
||||||
|
}
|
||||||
|
localCfg.TotalPrice = &total
|
||||||
|
localCfg.UpdatedAt = time.Now()
|
||||||
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
|
var cfg *models.Configuration
|
||||||
|
err = s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Save(localCfg).Error; err != nil {
|
||||||
|
return fmt.Errorf("save local configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing current version for the pending change
|
||||||
|
var version localdb.LocalConfigurationVersion
|
||||||
|
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
||||||
|
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err != nil {
|
||||||
|
return fmt.Errorf("load current version: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||||
|
Order("version_no DESC").First(&version).Error; err != nil {
|
||||||
|
return fmt.Errorf("load latest version: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg = localdb.LocalToConfiguration(localCfg)
|
||||||
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", &version, ""); err != nil {
|
||||||
|
return fmt.Errorf("enqueue server-count pending change: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
||||||
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
||||||
return s.syncService.ImportConfigurationsToLocal()
|
return s.syncService.ImportConfigurationsToLocal()
|
||||||
|
|||||||
93
markdown
Normal file
93
markdown
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<p><strong>ТЕХНИЧЕСКОЕ ЗАДАНИЕ</strong></p>
|
||||||
|
<p>1. Требования к продукции</p>
|
||||||
|
<p>Поставляемое оборудование должно быть новым, оригинальным, не бывшим
|
||||||
|
в употреблении, не восстановленным. Гарантийный срок — не менее 12
|
||||||
|
месяцев с момента поставки. Все компоненты, включая процессоры, память,
|
||||||
|
накопители и контроллеры, должны быть совместимы и предварительно
|
||||||
|
протестированы на совместимость и производительность в рамках единой
|
||||||
|
системы.</p>
|
||||||
|
<p>2. Количественные и качественные характеристики</p>
|
||||||
|
<p>2.1. Базовые требования к серверной платформе:</p>
|
||||||
|
<p>Модель (артикул): Сервер в конфигурации с шасси, поддерживающим 12
|
||||||
|
отсека 2.5" для накопителей NVMe U.2/U.3.</p>
|
||||||
|
<p>Форм-фактор: 2U для установки в стойку.</p>
|
||||||
|
<p>Кол-во процессорных сокетов: 1.</p>
|
||||||
|
<p>2.2. Требования к процессорам (CPU):</p>
|
||||||
|
<p>Количество: 1 шт.</p>
|
||||||
|
<p>Модель/семейство: 256t x AMD EPYC 9755 2.7 GHz 128c-Core Processor
|
||||||
|
Объём кэша L3: 512MB Техпроцесс: 4 нм, архитектура процессора: Zen-5
|
||||||
|
(Turin).</p>
|
||||||
|
<p>Минимальная базовая тактовая частота: 2.7 ГГц.</p>
|
||||||
|
<p>Максимальная частота работы процессора (Turboboost): 4.1 GHz</p>
|
||||||
|
<p>Для обеспечения полной производительности всех 12 накопителей NVMe и
|
||||||
|
сетевых адаптеров, процессор и системная платформа в целом должны
|
||||||
|
обеспечивать достаточно линий PCIe 5.0.</p>
|
||||||
|
<p>2.3. Требования к оперативной памяти (RAM):</p>
|
||||||
|
<p>Тип памяти: DDR5 с коррекцией ошибок (ECC) RDIMM 6000Mhz.</p>
|
||||||
|
<p>Минимальный объем оперативной памяти: 2048 ГБ.</p>
|
||||||
|
<p>Конфигурация: Модули памяти должны быть установлены в оптимальной
|
||||||
|
конфигурации для обеспечения полной пропускной способности всех линий
|
||||||
|
PCIe 5.0 от NVMe-накопителей. Платформа должна поддерживать установку не
|
||||||
|
менее 16 модулей DDR5 ECC REG 6000Mhz для последующего
|
||||||
|
масштабирования.</p>
|
||||||
|
<p>Поддерживаемая частота: Не менее 6000 МТ/с.</p>
|
||||||
|
<p>Одобренные модули оперативной памяти - Samsung/Micron/Hynix, DDR5,
|
||||||
|
64GB, RDIMM, ECC</p>
|
||||||
|
<p>2.4 Требования к системе хранения данных:</p>
|
||||||
|
<p>Конфигурация шасси: Обязательна поставка в конфигурации с 12 отсеками
|
||||||
|
2.5" под горячую замену, поддерживающими интерфейс NVMe через PCIe 5.0
|
||||||
|
x4 и 2 отсеками 2.5"/М.2 под горячую замену, поддерживающими SATA.</p>
|
||||||
|
<p>Дополнительно (для ОС): Поддержка установки 2x M.2 NVMe накопителей в
|
||||||
|
dedicated-слотах на материнской плате под операционную систему отдельно
|
||||||
|
от основного хранилища данных.</p>
|
||||||
|
<p>2.5. Требования к сетевым интерфейсам (NIC):</p>
|
||||||
|
<p>Слоты расширения сети: Наличие не менее 1 слотов OCP 3.0 SFF для
|
||||||
|
установки специализированных сетевых адаптеров.</p>
|
||||||
|
<p>Дополнительные сетевые адаптеры (обязательная поставка): Один сетевой
|
||||||
|
адаптер OCP 3.0 с 2 портами 25 Гбит/с Ethernet Intel 810 или Mellanox
|
||||||
|
CX-6.</p>
|
||||||
|
<p>Встроенные порты управления: порт 1 Гбит/с Ethernet (RJ-45) для
|
||||||
|
модуля управления iBMC.</p>
|
||||||
|
<p>2.6. Требования к интерфейсам расширения (PCIe):</p>
|
||||||
|
<p>Количество слотов PCIe: Конфигурация с 12 дисками NVMe использует
|
||||||
|
большую часть линий PCIe. Тем не менее, не менее слотов PCIe 5.0 должны
|
||||||
|
оставаться свободными для будущего расширения (например, установки
|
||||||
|
дополнительных сетевых карт или FPGA/GPU для специфических задач).</p>
|
||||||
|
<p>Шинная архитектура: Поставщик должен предоставить схему распределения
|
||||||
|
линий PCIe 5.0 между процессором контроллером RAID и слотами расширения,
|
||||||
|
подтверждающую отсутствие узких мест (bottlenecks).</p>
|
||||||
|
<p>2.7. Требования к системе управления:</p>
|
||||||
|
<p>Внеполосный (out-of-band) модуль управления: Наличие выделенного чипа
|
||||||
|
iBMC.</p>
|
||||||
|
<p>Интеллектуальные функции: Критически важна поддержка детального
|
||||||
|
мониторинга состояния NVMe-накопителей (SMART, температура, износ,
|
||||||
|
прогнозирование сбоев) через интерфейс iBMC и поддержка технологии
|
||||||
|
горячей замены NVMe-накопителей.</p>
|
||||||
|
<p>Наличие безагентного КВМ (HTML5)</p>
|
||||||
|
<p>Желательна поддержка shared LAN (через NCSI OCPv3 разъем) с
|
||||||
|
тегированием VLAN, настройка по умолчанию DHCP IPv4</p>
|
||||||
|
<p>Управление параметрами работы сервера (режим работы вентиляторов,
|
||||||
|
потребление энергии, итд).</p>
|
||||||
|
<p>Наличие 2х видов логирования:</p>
|
||||||
|
<p>Все аппаратные события, включая ECC ошибки по памяти, ошибки PCIe,
|
||||||
|
SATA (IPMI/Hardware Event Log)</p>
|
||||||
|
<p>Все сессии аутентификации и изменения системных параметров
|
||||||
|
(Audit/Security Log)</p>
|
||||||
|
<p>Наличие функционала обновления прошивок сервера (BIOS, BMC, CPLD
|
||||||
|
(опционально)) с сохранением и без сохранения настроек.</p>
|
||||||
|
<p>2.8. Требования к системе питания и охлаждения:</p>
|
||||||
|
<p>Блоки питания (PSU):</p>
|
||||||
|
<p>Количество: 2 шт. (резервирование 1+1) с возможностью горячей
|
||||||
|
замены.</p>
|
||||||
|
<p>Номинальная мощность каждого: Минимум 1200 Вт с сертификацией 80 Plus
|
||||||
|
Platinum/Titanium. Мощность должна быть достаточной для одновременной
|
||||||
|
пиковой нагрузки от процессора, 12 NVMe-дисков и прочих компонентов.</p>
|
||||||
|
<p>Система охлаждения: не менее N+1 резервирование вентиляторов.</p>
|
||||||
|
<p>3. Упаковка и маркировка:</p>
|
||||||
|
<p>Оборудование должно быть упаковано так, чтобы предотвратить
|
||||||
|
повреждение при транспортировке.</p>
|
||||||
|
<p>4. Требования к комплектации:</p>
|
||||||
|
<p>Рельсовый комплект - без инструментов с горизонтальной загрузкой</p>
|
||||||
|
<p>Оборудование - C19-C20 или С13-С14 кабели питания 1-2 m в зависимости
|
||||||
|
от БП, 19 " комплект для монтажа в стойку, комплект винтов, все отсеки с
|
||||||
|
корзинами.</p>
|
||||||
@@ -1988,7 +1988,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, project_uuid: projectUUID, article: article})
|
body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID, article: article, server_count: serverCount, pricelist_id: selectedPricelistIds.estimate || null})
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-5 gap-3">
|
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-6 gap-3">
|
||||||
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
||||||
+ Новый вариант
|
+ Новый вариант
|
||||||
</button>
|
</button>
|
||||||
@@ -43,6 +43,9 @@
|
|||||||
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
||||||
Параметры
|
Параметры
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="exportProject()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
|
||||||
|
Экспорт CSV
|
||||||
|
</button>
|
||||||
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
|
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
|
||||||
Удалить вариант
|
Удалить вариант
|
||||||
</button>
|
</button>
|
||||||
@@ -383,8 +386,12 @@ function renderConfigs(configs) {
|
|||||||
}
|
}
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||||
|
if (configStatusMode === 'archived') {
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
} else {
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
|
||||||
|
}
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||||
const versionNo = c.current_version_no || 1;
|
const versionNo = c.current_version_no || 1;
|
||||||
html += '<td class="px-4 py-3 text-sm text-center text-gray-500">v' + versionNo + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-center text-gray-500">v' + versionNo + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||||
@@ -411,7 +418,7 @@ function renderConfigs(configs) {
|
|||||||
html += '<tr>';
|
html += '<tr>';
|
||||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="4">Итого по проекту</td>';
|
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="4">Итого по проекту</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900" data-footer-total="1">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||||
html += '<td class="px-4 py-3"></td>';
|
html += '<td class="px-4 py-3"></td>';
|
||||||
html += '<td class="px-4 py-3"></td>';
|
html += '<td class="px-4 py-3"></td>';
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
@@ -874,6 +881,60 @@ document.addEventListener('keydown', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function updateConfigServerCount(input) {
|
||||||
|
const uuid = input.dataset.uuid;
|
||||||
|
const prevValue = parseInt(input.dataset.prev) || 1;
|
||||||
|
const newValue = parseInt(input.value);
|
||||||
|
if (!newValue || newValue < 1) {
|
||||||
|
input.value = prevValue;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/' + uuid + '/server-count', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({server_count: newValue})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
input.value = prevValue;
|
||||||
|
showToast('Не удалось обновить количество', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await resp.json();
|
||||||
|
input.dataset.prev = newValue;
|
||||||
|
// Update row total price
|
||||||
|
const totalCell = document.querySelector('[data-total-uuid="' + uuid + '"]');
|
||||||
|
if (totalCell && updated.total_price != null) {
|
||||||
|
totalCell.textContent = '$' + updated.total_price.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||||
|
}
|
||||||
|
// Update the config in allConfigs and recalculate footer total
|
||||||
|
for (let i = 0; i < allConfigs.length; i++) {
|
||||||
|
if (allConfigs[i].uuid === uuid) {
|
||||||
|
allConfigs[i].total_price = updated.total_price;
|
||||||
|
allConfigs[i].server_count = newValue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateFooterTotal();
|
||||||
|
} catch (e) {
|
||||||
|
input.value = prevValue;
|
||||||
|
showToast('Ошибка сети', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFooterTotal() {
|
||||||
|
let totalSum = 0;
|
||||||
|
allConfigs.forEach(c => { totalSum += (c.total_price || 0); });
|
||||||
|
const footer = document.querySelector('tfoot td[data-footer-total]');
|
||||||
|
if (footer) {
|
||||||
|
footer.textContent = '$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportProject() {
|
||||||
|
window.location.href = '/api/projects/' + projectUUID + '/export';
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async function() {
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
applyStatusModeUI();
|
applyStatusModeUI();
|
||||||
const ok = await loadProject();
|
const ok = await loadProject();
|
||||||
|
|||||||
Reference in New Issue
Block a user