Compare commits
7 Commits
v1.3.1
...
87cb12906d
| Author | SHA1 | Date | |
|---|---|---|---|
| 87cb12906d | |||
| 075fc709dd | |||
| cbaeafa9c8 | |||
| 71f73e2f1d | |||
| 2e973b6d78 | |||
| 8508ee2921 | |||
| b153afbf51 |
@@ -20,18 +20,22 @@
|
|||||||
- Local-first: чтение и запись происходят в SQLite
|
- Local-first: чтение и запись происходят в SQLite
|
||||||
- MariaDB используется как сервер синхронизации
|
- MariaDB используется как сервер синхронизации
|
||||||
- Background worker: периодический sync push+pull
|
- Background worker: периодический sync push+pull
|
||||||
|
- Система ревизий конфигураций: immutable snapshots при каждом сохранении (local_configuration_versions)
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
|
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
|
||||||
- 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`.
|
||||||
|
- UI: во всех breadcrumbs длинные названия спецификаций/конфигураций сокращать до 16 символов с многоточием.
|
||||||
|
|
||||||
## Key SQLite Data
|
## Key SQLite Data
|
||||||
- `connection_settings`
|
- `connection_settings`
|
||||||
- `local_components`
|
- `local_components`
|
||||||
- `local_pricelists`, `local_pricelist_items`
|
- `local_pricelists`, `local_pricelist_items`
|
||||||
- `local_configurations`
|
- `local_configurations`
|
||||||
|
- `local_configuration_versions` — immutable snapshots (ревизии) при каждом сохранении
|
||||||
- `local_projects`
|
- `local_projects`
|
||||||
- `pending_changes`
|
- `pending_changes`
|
||||||
|
|
||||||
@@ -42,14 +46,15 @@
|
|||||||
| Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
|
| Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
|
||||||
| Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
|
| Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
|
||||||
| Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
|
| Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
|
||||||
| Configs | CRUD + refresh/clone/reactivate/rename/project binding via `/api/configs/*` |
|
| Configs | CRUD + refresh/clone/reactivate/rename/project binding + versions/rollback via `/api/configs/*` |
|
||||||
| Projects | CRUD + nested configs via `/api/projects/*` |
|
| Projects | CRUD + nested configs + `DELETE /api/projects/:uuid` (delete variant) via `/api/projects/*` |
|
||||||
| Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` |
|
| Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` |
|
||||||
| Export | `POST /api/export/csv` |
|
| Export | `POST /api/export/csv` |
|
||||||
|
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- `/configs`
|
- `/configs`
|
||||||
- `/configurator`
|
- `/configurator`
|
||||||
|
- `/configs/:uuid/revisions`
|
||||||
- `/projects`
|
- `/projects`
|
||||||
- `/projects/:uuid`
|
- `/projects/:uuid`
|
||||||
- `/pricelists`
|
- `/pricelists`
|
||||||
|
|||||||
105
cmd/qfs/main.go
105
cmd/qfs/main.go
@@ -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 {
|
||||||
@@ -930,6 +930,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
router.GET("/configurator", webHandler.Configurator)
|
router.GET("/configurator", webHandler.Configurator)
|
||||||
router.GET("/projects", webHandler.Projects)
|
router.GET("/projects", webHandler.Projects)
|
||||||
router.GET("/projects/:uuid", webHandler.ProjectDetail)
|
router.GET("/projects/:uuid", webHandler.ProjectDetail)
|
||||||
|
router.GET("/configs/:uuid/revisions", webHandler.ConfigRevisions)
|
||||||
router.GET("/pricelists", webHandler.Pricelists)
|
router.GET("/pricelists", webHandler.Pricelists)
|
||||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||||
|
|
||||||
@@ -1078,7 +1079,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
config, err := configService.UpdateNoAuth(uuid, &req)
|
config, err := configService.UpdateNoAuth(uuid, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrConfigNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1130,14 +1140,19 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
FromVersion int `json:"from_version"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := configService.CloneNoAuth(uuid, req.Name, dbUsername)
|
config, err := configService.CloneNoAuthToProjectFromVersion(uuid, req.Name, dbUsername, nil, req.FromVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, services.ErrConfigVersionNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1286,6 +1301,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")
|
||||||
@@ -1579,6 +1611,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
|
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
projects.DELETE("/:uuid", func(c *gin.Context) {
|
||||||
|
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrCannotDeleteMainVariant):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "variant deleted"})
|
||||||
|
})
|
||||||
|
|
||||||
projects.GET("/:uuid/configs", func(c *gin.Context) {
|
projects.GET("/:uuid/configs", func(c *gin.Context) {
|
||||||
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
|
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
|
||||||
|
|
||||||
@@ -1621,6 +1670,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"`
|
||||||
@@ -1653,6 +1704,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
||||||
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
||||||
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
|
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
|
||||||
|
syncAPI.POST("/repair", syncHandler.RepairPendingChanges)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1704,11 +1756,37 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
query := c.Request.URL.RawQuery
|
query := c.Request.URL.RawQuery
|
||||||
|
|
||||||
|
blw := &captureResponseWriter{
|
||||||
|
ResponseWriter: c.Writer,
|
||||||
|
body: bytes.NewBuffer(nil),
|
||||||
|
}
|
||||||
|
c.Writer = blw
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
status := c.Writer.Status()
|
status := c.Writer.Status()
|
||||||
|
|
||||||
|
if status >= http.StatusBadRequest {
|
||||||
|
responseBody := strings.TrimSpace(blw.body.String())
|
||||||
|
if len(responseBody) > 2048 {
|
||||||
|
responseBody = responseBody[:2048] + "...(truncated)"
|
||||||
|
}
|
||||||
|
errText := strings.TrimSpace(c.Errors.String())
|
||||||
|
|
||||||
|
slog.Error("request failed",
|
||||||
|
"method", c.Request.Method,
|
||||||
|
"path", path,
|
||||||
|
"query", query,
|
||||||
|
"status", status,
|
||||||
|
"latency", latency,
|
||||||
|
"ip", c.ClientIP(),
|
||||||
|
"errors", errText,
|
||||||
|
"response", responseBody,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("request",
|
slog.Info("request",
|
||||||
"method", c.Request.Method,
|
"method", c.Request.Method,
|
||||||
"path", path,
|
"path", path,
|
||||||
@@ -1719,3 +1797,22 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type captureResponseWriter struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
body *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *captureResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
if len(b) > 0 {
|
||||||
|
_, _ = w.body.Write(b)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *captureResponseWriter) WriteString(s string) (int, error) {
|
||||||
|
if s != "" {
|
||||||
|
_, _ = w.body.WriteString(s)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.WriteString(s)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -419,6 +419,26 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RepairPendingChanges attempts to repair errored pending changes
|
||||||
|
// POST /api/sync/repair
|
||||||
|
func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
|
||||||
|
repaired, remainingErrors, err := h.localDB.RepairPendingChanges()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("repair pending changes failed", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"repaired": repaired,
|
||||||
|
"remaining_errors": remainingErrors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SyncInfoResponse represents sync information for the modal
|
// SyncInfoResponse represents sync information for the modal
|
||||||
type SyncInfoResponse struct {
|
type SyncInfoResponse struct {
|
||||||
// Connection
|
// Connection
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load each page template with base
|
// Load each page template with base
|
||||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html"}
|
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
|
||||||
for _, page := range simplePages {
|
for _, page := range simplePages {
|
||||||
pagePath := filepath.Join(templatesPath, page)
|
pagePath := filepath.Join(templatesPath, page)
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
@@ -197,6 +197,13 @@ func (h *WebHandler) ProjectDetail(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) ConfigRevisions(c *gin.Context) {
|
||||||
|
h.render(c, "config_revisions.html", gin.H{
|
||||||
|
"ActivePage": "configs",
|
||||||
|
"ConfigUUID": c.Param("uuid"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WebHandler) Pricelists(c *gin.Context) {
|
func (h *WebHandler) Pricelists(c *gin.Context) {
|
||||||
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
userID := local.OriginalUserID
|
userID := local.OriginalUserID
|
||||||
cfg.UserID = &userID
|
cfg.UserID = &userID
|
||||||
}
|
}
|
||||||
|
if local.CurrentVersion != nil {
|
||||||
|
cfg.CurrentVersionNo = local.CurrentVersion.VersionNo
|
||||||
|
}
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||||
@@ -125,3 +127,129 @@ func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
|
|||||||
t.Fatalf("expected 2 pricelists, got %d", count)
|
t.Fatalf("expected 2 pricelists, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "versions_dedup.db")
|
||||||
|
|
||||||
|
local, err := New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
cfg := &LocalConfiguration{
|
||||||
|
UUID: "dedup-cfg",
|
||||||
|
Name: "Dedup",
|
||||||
|
Items: LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
SyncStatus: "pending",
|
||||||
|
OriginalUsername: "tester",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := local.SaveConfiguration(cfg); err != nil {
|
||||||
|
t.Fatalf("save seed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseV1Data, err := BuildConfigurationSnapshot(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build v1 snapshot: %v", err)
|
||||||
|
}
|
||||||
|
baseV1 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 1,
|
||||||
|
Data: baseV1Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&baseV1).Error; err != nil {
|
||||||
|
t.Fatalf("insert base v1: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.DB().Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", baseV1.ID).Error; err != nil {
|
||||||
|
t.Fatalf("set current_version_id to v1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v2 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 2,
|
||||||
|
Data: baseV1.Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now().Add(1 * time.Second),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&v2).Error; err != nil {
|
||||||
|
t.Fatalf("insert duplicate v2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := *cfg
|
||||||
|
modified.Items = LocalConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}}
|
||||||
|
total := modified.Items.Total()
|
||||||
|
modified.TotalPrice = &total
|
||||||
|
modified.UpdatedAt = time.Now()
|
||||||
|
v3Data, err := BuildConfigurationSnapshot(&modified)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build v3 snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v3 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 3,
|
||||||
|
Data: v3Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now().Add(2 * time.Second),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&v3).Error; err != nil {
|
||||||
|
t.Fatalf("insert v3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v4 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 4,
|
||||||
|
Data: v3Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now().Add(3 * time.Second),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&v4).Error; err != nil {
|
||||||
|
t.Fatalf("insert duplicate v4: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.DB().Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", v4.ID).Error; err != nil {
|
||||||
|
t.Fatalf("point current_version_id to duplicate v4: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.DB().Where("id = ?", "2026_02_19_configuration_versions_dedup_spec_price").
|
||||||
|
Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete dedup migration record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runLocalMigrations(local.DB()); err != nil {
|
||||||
|
t.Fatalf("rerun local migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var versions []LocalConfigurationVersion
|
||||||
|
if err := local.DB().Where("configuration_uuid = ?", cfg.UUID).
|
||||||
|
Order("version_no ASC").
|
||||||
|
Find(&versions).Error; err != nil {
|
||||||
|
t.Fatalf("load versions after dedup: %v", err)
|
||||||
|
}
|
||||||
|
if len(versions) != 2 {
|
||||||
|
t.Fatalf("expected 2 versions after dedup, got %d", len(versions))
|
||||||
|
}
|
||||||
|
if versions[0].VersionNo != 1 || versions[1].VersionNo != 3 {
|
||||||
|
t.Fatalf("expected kept version numbers [1,3], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
var after LocalConfiguration
|
||||||
|
if err := local.DB().Where("uuid = ?", cfg.UUID).First(&after).Error; err != nil {
|
||||||
|
t.Fatalf("load config after dedup: %v", err)
|
||||||
|
}
|
||||||
|
if after.CurrentVersionID == nil || *after.CurrentVersionID != v3.ID {
|
||||||
|
t.Fatalf("expected current_version_id to point to kept latest version v3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1041,6 +1041,145 @@ func (l *LocalDB) GetPendingCount() int64 {
|
|||||||
return l.CountPendingChanges()
|
return l.CountPendingChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RepairPendingChanges attempts to fix errored pending changes by validating and correcting data.
|
||||||
|
// Returns the number of changes repaired and a list of errors that couldn't be fixed.
|
||||||
|
func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
|
||||||
|
var erroredChanges []PendingChange
|
||||||
|
if err := l.db.Where("last_error != ?", "").Find(&erroredChanges).Error; err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("fetching errored changes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(erroredChanges) == 0 {
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
repaired := 0
|
||||||
|
var remainingErrors []string
|
||||||
|
|
||||||
|
for _, change := range erroredChanges {
|
||||||
|
var repairErr error
|
||||||
|
switch change.EntityType {
|
||||||
|
case "project":
|
||||||
|
repairErr = l.repairProjectChange(&change)
|
||||||
|
case "configuration":
|
||||||
|
repairErr = l.repairConfigurationChange(&change)
|
||||||
|
default:
|
||||||
|
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if repairErr != nil {
|
||||||
|
remainingErrors = append(remainingErrors, fmt.Sprintf("%s %s %s: %v",
|
||||||
|
change.Operation, change.EntityType, change.EntityUUID[:8], repairErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear error and reset attempts
|
||||||
|
if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
|
||||||
|
"last_error": "",
|
||||||
|
"attempts": 0,
|
||||||
|
}).Error; err != nil {
|
||||||
|
remainingErrors = append(remainingErrors, fmt.Sprintf("clearing error for %s: %v", change.EntityUUID[:8], err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
repaired++
|
||||||
|
}
|
||||||
|
|
||||||
|
return repaired, remainingErrors, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairProjectChange validates and fixes project data.
|
||||||
|
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
|
||||||
|
// are handled by sync service layer with deduplication logic.
|
||||||
|
func (l *LocalDB) repairProjectChange(change *PendingChange) error {
|
||||||
|
project, err := l.GetProjectByUUID(change.EntityUUID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("project not found locally: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := false
|
||||||
|
|
||||||
|
// Fix Code: must be non-empty
|
||||||
|
if strings.TrimSpace(project.Code) == "" {
|
||||||
|
if project.Name != nil && strings.TrimSpace(*project.Name) != "" {
|
||||||
|
project.Code = strings.TrimSpace(*project.Name)
|
||||||
|
} else {
|
||||||
|
project.Code = project.UUID[:8]
|
||||||
|
}
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix Name: use Code if empty
|
||||||
|
if project.Name == nil || strings.TrimSpace(*project.Name) == "" {
|
||||||
|
name := project.Code
|
||||||
|
project.Name = &name
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix OwnerUsername: must be non-empty
|
||||||
|
if strings.TrimSpace(project.OwnerUsername) == "" {
|
||||||
|
project.OwnerUsername = l.GetDBUser()
|
||||||
|
if project.OwnerUsername == "" {
|
||||||
|
return fmt.Errorf("cannot determine owner username")
|
||||||
|
}
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for local duplicates with same (code, variant)
|
||||||
|
var duplicate LocalProject
|
||||||
|
err = l.db.Where("code = ? AND variant = ? AND uuid != ?", project.Code, project.Variant, project.UUID).
|
||||||
|
First(&duplicate).Error
|
||||||
|
if err == nil {
|
||||||
|
// Found local duplicate - deduplicate by appending UUID suffix to variant
|
||||||
|
if project.Variant == "" {
|
||||||
|
project.Variant = project.UUID[:8]
|
||||||
|
} else {
|
||||||
|
project.Variant = project.Variant + "-" + project.UUID[:8]
|
||||||
|
}
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if modified {
|
||||||
|
if err := l.SaveProject(project); err != nil {
|
||||||
|
return fmt.Errorf("saving repaired project: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairConfigurationChange validates and fixes configuration data
|
||||||
|
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
|
||||||
|
config, err := l.GetConfigurationByUUID(change.EntityUUID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("configuration not found locally: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := false
|
||||||
|
|
||||||
|
// Check if referenced project exists
|
||||||
|
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||||
|
_, err := l.GetProjectByUUID(*config.ProjectUUID)
|
||||||
|
if err != nil {
|
||||||
|
// Project doesn't exist locally - use default system project
|
||||||
|
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
|
||||||
|
if sysErr != nil {
|
||||||
|
return fmt.Errorf("getting system project: %w", sysErr)
|
||||||
|
}
|
||||||
|
config.ProjectUUID = &systemProject.UUID
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if modified {
|
||||||
|
if err := l.SaveConfiguration(config); err != nil {
|
||||||
|
return fmt.Errorf("saving repaired configuration: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
|
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
|
||||||
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
|
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
|
||||||
var migration LocalRemoteMigrationApplied
|
var migration LocalRemoteMigrationApplied
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ var localMigrations = []localMigration{
|
|||||||
name: "Allow NULL project names in local_projects",
|
name: "Allow NULL project names in local_projects",
|
||||||
run: allowLocalProjectNameNull,
|
run: allowLocalProjectNameNull,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_19_configuration_versions_dedup_spec_price",
|
||||||
|
name: "Deduplicate configuration revisions by spec+price",
|
||||||
|
run: deduplicateConfigurationVersionsBySpecAndPrice,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLocalMigrations(db *gorm.DB) error {
|
func runLocalMigrations(db *gorm.DB) error {
|
||||||
@@ -428,6 +433,92 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deduplicateConfigurationVersionsBySpecAndPrice(tx *gorm.DB) error {
|
||||||
|
var configs []LocalConfiguration
|
||||||
|
if err := tx.Select("uuid", "current_version_id").Find(&configs).Error; err != nil {
|
||||||
|
return fmt.Errorf("load configurations for revision deduplication: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var removedTotal int
|
||||||
|
for i := range configs {
|
||||||
|
cfg := configs[i]
|
||||||
|
|
||||||
|
var versions []LocalConfigurationVersion
|
||||||
|
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
||||||
|
Order("version_no ASC, created_at ASC").
|
||||||
|
Find(&versions).Error; err != nil {
|
||||||
|
return fmt.Errorf("load versions for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
if len(versions) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteIDs := make([]string, 0)
|
||||||
|
deleteSet := make(map[string]struct{})
|
||||||
|
kept := make([]LocalConfigurationVersion, 0, len(versions))
|
||||||
|
var prevKey string
|
||||||
|
hasPrev := false
|
||||||
|
|
||||||
|
for _, version := range versions {
|
||||||
|
snapshotCfg, err := DecodeConfigurationSnapshot(version.Data)
|
||||||
|
if err != nil {
|
||||||
|
// Keep malformed snapshots untouched and reset chain to avoid accidental removals.
|
||||||
|
kept = append(kept, version)
|
||||||
|
hasPrev = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := BuildConfigurationSpecPriceFingerprint(snapshotCfg)
|
||||||
|
if err != nil {
|
||||||
|
kept = append(kept, version)
|
||||||
|
hasPrev = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasPrev || key != prevKey {
|
||||||
|
kept = append(kept, version)
|
||||||
|
prevKey = key
|
||||||
|
hasPrev = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteIDs = append(deleteIDs, version.ID)
|
||||||
|
deleteSet[version.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(deleteIDs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Where("id IN ?", deleteIDs).Delete(&LocalConfigurationVersion{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("delete duplicate versions for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
removedTotal += len(deleteIDs)
|
||||||
|
|
||||||
|
latestKeptID := kept[len(kept)-1].ID
|
||||||
|
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
||||||
|
if err := tx.Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", latestKeptID).Error; err != nil {
|
||||||
|
return fmt.Errorf("set missing current_version_id for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, deleted := deleteSet[*cfg.CurrentVersionID]; deleted {
|
||||||
|
if err := tx.Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", latestKeptID).Error; err != nil {
|
||||||
|
return fmt.Errorf("repair current_version_id for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if removedTotal > 0 {
|
||||||
|
slog.Info("deduplicated configuration revisions", "removed_versions", removedTotal)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
||||||
type indexRow struct {
|
type indexRow struct {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package localdb
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,3 +95,51 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
OriginalUsername: snapshot.OriginalUsername,
|
OriginalUsername: snapshot.OriginalUsername,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type configurationSpecPriceFingerprint struct {
|
||||||
|
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
|
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||||
|
CustomPrice *float64 `json:"custom_price,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type configurationSpecPriceFingerprintItem struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildConfigurationSpecPriceFingerprint returns a stable JSON key based on
|
||||||
|
// spec + price fields only, used for revision deduplication.
|
||||||
|
func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (string, error) {
|
||||||
|
items := make([]configurationSpecPriceFingerprintItem, 0, len(localCfg.Items))
|
||||||
|
for _, item := range localCfg.Items {
|
||||||
|
items = append(items, configurationSpecPriceFingerprintItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].LotName != items[j].LotName {
|
||||||
|
return items[i].LotName < items[j].LotName
|
||||||
|
}
|
||||||
|
if items[i].Quantity != items[j].Quantity {
|
||||||
|
return items[i].Quantity < items[j].Quantity
|
||||||
|
}
|
||||||
|
return items[i].UnitPrice < items[j].UnitPrice
|
||||||
|
})
|
||||||
|
|
||||||
|
payload := configurationSpecPriceFingerprint{
|
||||||
|
Items: items,
|
||||||
|
ServerCount: localCfg.ServerCount,
|
||||||
|
TotalPrice: localCfg.TotalPrice,
|
||||||
|
CustomPrice: localCfg.CustomPrice,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal spec+price fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type Configuration struct {
|
|||||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
|
||||||
|
|
||||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|
||||||
|
|||||||
@@ -442,14 +442,14 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
|
|
||||||
// GetByUUIDNoAuth returns configuration without ownership check
|
// GetByUUIDNoAuth returns configuration without ownership check
|
||||||
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
var localCfg localdb.LocalConfiguration
|
||||||
if err != nil {
|
if err := s.localDB.DB().Preload("CurrentVersion").Where("uuid = ?", uuid).First(&localCfg).Error; err != nil {
|
||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
if !localCfg.IsActive {
|
if !localCfg.IsActive {
|
||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
return localdb.LocalToConfiguration(localCfg), nil
|
return localdb.LocalToConfiguration(&localCfg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateNoAuth updates configuration without ownership check
|
// UpdateNoAuth updates configuration without ownership check
|
||||||
@@ -461,11 +461,23 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
|
|
||||||
projectUUID := localCfg.ProjectUUID
|
projectUUID := localCfg.ProjectUUID
|
||||||
if req.ProjectUUID != nil {
|
if req.ProjectUUID != nil {
|
||||||
|
requestedProjectUUID := strings.TrimSpace(*req.ProjectUUID)
|
||||||
|
currentProjectUUID := ""
|
||||||
|
if localCfg.ProjectUUID != nil {
|
||||||
|
currentProjectUUID = strings.TrimSpace(*localCfg.ProjectUUID)
|
||||||
|
}
|
||||||
|
|
||||||
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Allow save for legacy/orphaned configs when request keeps the same project UUID.
|
||||||
|
// This can happen for imported configs whose project is not present in local cache.
|
||||||
|
if errors.Is(err, ErrProjectNotFound) && requestedProjectUUID != "" && requestedProjectUUID == currentProjectUUID {
|
||||||
|
projectUUID = localCfg.ProjectUUID
|
||||||
|
} else {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -571,10 +583,30 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||||
|
return s.CloneNoAuthToProjectFromVersion(configUUID, newName, ownerUsername, projectUUID, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID string, newName string, ownerUsername string, projectUUID *string, fromVersion int) (*models.Configuration, error) {
|
||||||
original, err := s.GetByUUIDNoAuth(configUUID)
|
original, err := s.GetByUUIDNoAuth(configUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If fromVersion specified, use snapshot from that version
|
||||||
|
if fromVersion > 0 {
|
||||||
|
version, vErr := s.GetVersion(configUUID, fromVersion)
|
||||||
|
if vErr != nil {
|
||||||
|
return nil, vErr
|
||||||
|
}
|
||||||
|
snapshot, decErr := s.decodeConfigurationSnapshot(version.Data)
|
||||||
|
if decErr != nil {
|
||||||
|
return nil, fmt.Errorf("decode version snapshot for clone: %w", decErr)
|
||||||
|
}
|
||||||
|
snapshotCfg := localdb.LocalToConfiguration(snapshot)
|
||||||
|
original = snapshotCfg
|
||||||
|
original.UUID = configUUID // preserve original UUID for project resolution
|
||||||
|
}
|
||||||
|
|
||||||
resolvedProjectUUID := original.ProjectUUID
|
resolvedProjectUUID := original.ProjectUUID
|
||||||
if projectUUID != nil {
|
if projectUUID != nil {
|
||||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||||
@@ -768,6 +800,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()
|
||||||
@@ -896,6 +980,7 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
|
|||||||
return fmt.Errorf("set current version id: %w", err)
|
return fmt.Errorf("set current version id: %w", err)
|
||||||
}
|
}
|
||||||
localCfg.CurrentVersionID = &version.ID
|
localCfg.CurrentVersionID = &version.ID
|
||||||
|
localCfg.CurrentVersion = version
|
||||||
|
|
||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||||
return fmt.Errorf("enqueue create pending change: %w", err)
|
return fmt.Errorf("enqueue create pending change: %w", err)
|
||||||
@@ -922,6 +1007,26 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return fmt.Errorf("lock configuration row: %w", err)
|
return fmt.Errorf("lock configuration row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if operation == "update" {
|
||||||
|
currentVersion, err := s.loadCurrentVersionTx(tx, &locked)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load current version before save: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy/orphaned rows may have empty or stale current_version_id.
|
||||||
|
// In that case we treat update as content-changing and append a fresh version.
|
||||||
|
if currentVersion != nil {
|
||||||
|
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compare revision content: %w", err)
|
||||||
|
}
|
||||||
|
if sameRevisionContent {
|
||||||
|
cfg = localdb.LocalToConfiguration(&locked)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Save(localCfg).Error; err != nil {
|
if err := tx.Save(localCfg).Error; err != nil {
|
||||||
return fmt.Errorf("save local configuration: %w", err)
|
return fmt.Errorf("save local configuration: %w", err)
|
||||||
}
|
}
|
||||||
@@ -937,6 +1042,7 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return fmt.Errorf("update current version id: %w", err)
|
return fmt.Errorf("update current version id: %w", err)
|
||||||
}
|
}
|
||||||
localCfg.CurrentVersionID = &version.ID
|
localCfg.CurrentVersionID = &version.ID
|
||||||
|
localCfg.CurrentVersion = version
|
||||||
|
|
||||||
cfg = localdb.LocalToConfiguration(localCfg)
|
cfg = localdb.LocalToConfiguration(localCfg)
|
||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
||||||
@@ -955,6 +1061,44 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
|
||||||
|
var version localdb.LocalConfigurationVersion
|
||||||
|
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
||||||
|
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err == nil {
|
||||||
|
return &version, nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||||
|
Order("version_no DESC").
|
||||||
|
First(&version).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) {
|
||||||
|
currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("decode current version snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(currentSnapshotCfg)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("build current snapshot fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
nextFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("build next snapshot fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
return currentFingerprint == nextFingerprint, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) appendVersionTx(
|
func (s *LocalConfigurationService) appendVersionTx(
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
localCfg *localdb.LocalConfiguration,
|
localCfg *localdb.LocalConfiguration,
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ func TestSaveCreatesNewVersionAndUpdatesCurrentPointer(t *testing.T) {
|
|||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "v2"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename config: %v", err)
|
Name: "v1",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
versions := loadVersions(t, local, created.UUID)
|
versions := loadVersions(t, local, created.UUID)
|
||||||
@@ -60,8 +64,12 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
|||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "changed"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename config: %v", err)
|
Name: "base",
|
||||||
|
Items: models.ConfigItems{{LotName: "RAM_A", Quantity: 3, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update config: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
||||||
t.Fatalf("rollback to v1: %v", err)
|
t.Fatalf("rollback to v1: %v", err)
|
||||||
@@ -79,6 +87,56 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "dedupe",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "dedupe",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first update config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "dedupe",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second update config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions := loadVersions(t, local, created.UUID)
|
||||||
|
if len(versions) != 2 {
|
||||||
|
t.Fatalf("expected 2 versions (create + first update), got %d", len(versions))
|
||||||
|
}
|
||||||
|
if versions[1].VersionNo != 2 {
|
||||||
|
t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingCount int64
|
||||||
|
if err := local.DB().
|
||||||
|
Table("pending_changes").
|
||||||
|
Where("entity_type = ? AND entity_uuid = ?", "configuration", created.UUID).
|
||||||
|
Count(&pendingCount).Error; err != nil {
|
||||||
|
t.Fatalf("count pending changes: %v", err)
|
||||||
|
}
|
||||||
|
if pendingCount != 2 {
|
||||||
|
t.Fatalf("expected 2 pending changes (create + first update), got %d", pendingCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
||||||
service, local := newLocalConfigServiceForTest(t)
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
@@ -97,8 +155,12 @@ func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
v1Before := versionsBefore[0]
|
v1Before := versionsBefore[0]
|
||||||
|
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "after-rename"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename config: %v", err)
|
Name: "initial",
|
||||||
|
Items: models.ConfigItems{{LotName: "SSD_A", Quantity: 2, UnitPrice: 300}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update config: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||||
t.Fatalf("rollback: %v", err)
|
t.Fatalf("rollback: %v", err)
|
||||||
@@ -144,7 +206,7 @@ func TestConcurrentSaveNoDuplicateVersionNumbers(t *testing.T) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
<-start
|
<-start
|
||||||
if err := renameWithRetry(service, created.UUID, fmt.Sprintf("name-%d", i)); err != nil {
|
if err := updateWithRetry(service, created.UUID, i+2); err != nil {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -228,6 +290,99 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoAuthAllowsOrphanProjectWhenUUIDUnchanged(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
project := &localdb.LocalProject{
|
||||||
|
UUID: "project-orphan",
|
||||||
|
OwnerUsername: "tester",
|
||||||
|
Code: "TEST-ORPHAN",
|
||||||
|
Name: ptrString("Orphan Project"),
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SyncStatus: "synced",
|
||||||
|
}
|
||||||
|
if err := local.SaveProject(project); err != nil {
|
||||||
|
t.Fatalf("save project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "cfg",
|
||||||
|
ProjectUUID: &project.UUID,
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate missing project in local cache while config still references its UUID.
|
||||||
|
if err := local.DB().Where("uuid = ?", project.UUID).Delete(&localdb.LocalProject{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "cfg-updated",
|
||||||
|
ProjectUUID: &project.UUID,
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update config with orphan project_uuid: %v", err)
|
||||||
|
}
|
||||||
|
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
|
||||||
|
t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoAuthRecoversWhenCurrentVersionMissing(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "cfg",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate corrupted/legacy versioning state:
|
||||||
|
// local configuration exists, but all version rows are gone and pointer is stale.
|
||||||
|
if err := local.DB().Where("configuration_uuid = ?", created.UUID).
|
||||||
|
Delete(&localdb.LocalConfigurationVersion{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete versions: %v", err)
|
||||||
|
}
|
||||||
|
staleID := "missing-version-id"
|
||||||
|
if err := local.DB().Model(&localdb.LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", created.UUID).
|
||||||
|
Update("current_version_id", staleID).Error; err != nil {
|
||||||
|
t.Fatalf("set stale current_version_id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "cfg-updated",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update config with missing current version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated.Name != "cfg-updated" {
|
||||||
|
t.Fatalf("expected updated name, got %q", updated.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions := loadVersions(t, local, created.UUID)
|
||||||
|
if len(versions) != 1 {
|
||||||
|
t.Fatalf("expected 1 recreated version, got %d", len(versions))
|
||||||
|
}
|
||||||
|
if versions[0].VersionNo != 1 {
|
||||||
|
t.Fatalf("expected recreated version_no=1, got %d", versions[0].VersionNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ptrString(value string) *string {
|
func ptrString(value string) *string {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
@@ -264,10 +419,14 @@ func loadVersions(t *testing.T, local *localdb.LocalDB, configurationUUID string
|
|||||||
return versions
|
return versions
|
||||||
}
|
}
|
||||||
|
|
||||||
func renameWithRetry(service *LocalConfigurationService, uuid string, name string) error {
|
func updateWithRetry(service *LocalConfigurationService, uuid string, quantity int) error {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for i := 0; i < 6; i++ {
|
for i := 0; i < 6; i++ {
|
||||||
_, err := service.RenameNoAuth(uuid, name)
|
_, err := service.UpdateNoAuth(uuid, &CreateConfigRequest{
|
||||||
|
Name: "base",
|
||||||
|
Items: models.ConfigItems{{LotName: "NIC_A", Quantity: quantity, UnitPrice: 150}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -278,7 +437,7 @@ func renameWithRetry(service *LocalConfigurationService, uuid string, name strin
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return fmt.Errorf("rename retries exhausted: %w", lastErr)
|
return fmt.Errorf("update retries exhausted: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
||||||
@@ -292,8 +451,12 @@ func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "second"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename: %v", err)
|
Name: "initial",
|
||||||
|
Items: models.ConfigItems{{LotName: "GPU_A", Quantity: 2, UnitPrice: 2000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||||
t.Fatalf("rollback: %v", err)
|
t.Fatalf("rollback: %v", err)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ var (
|
|||||||
ErrProjectNotFound = errors.New("project not found")
|
ErrProjectNotFound = errors.New("project not found")
|
||||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||||
|
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectService struct {
|
type ProjectService struct {
|
||||||
@@ -173,6 +174,17 @@ func (s *ProjectService) Reactivate(projectUUID, ownerUsername string) error {
|
|||||||
return s.setProjectActive(projectUUID, ownerUsername, true)
|
return s.setProjectActive(projectUUID, ownerUsername, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ProjectService) DeleteVariant(projectUUID, ownerUsername string) error {
|
||||||
|
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(localProject.Variant) == "" {
|
||||||
|
return ErrCannotDeleteMainVariant
|
||||||
|
}
|
||||||
|
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
||||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
var project localdb.LocalProject
|
var project localdb.LocalProject
|
||||||
@@ -263,8 +275,8 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
localConfigs, err := s.localDB.GetConfigurations()
|
var localConfigs []localdb.LocalConfiguration
|
||||||
if err != nil {
|
if err := s.localDB.DB().Preload("CurrentVersion").Order("created_at DESC").Find(&localConfigs).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -856,10 +856,29 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
// Try upsert by UUID first
|
||||||
|
err = projectRepo.UpsertByUUID(&project)
|
||||||
|
if err != nil {
|
||||||
|
// Check if it's a duplicate (code, variant) constraint violation
|
||||||
|
// In this case, find existing project with same (code, variant) and link to it
|
||||||
|
var existing models.Project
|
||||||
|
lookupErr := mariaDB.Where("code = ? AND variant = ?", project.Code, project.Variant).First(&existing).Error
|
||||||
|
if lookupErr == nil {
|
||||||
|
// Found duplicate - link local project to existing server project
|
||||||
|
slog.Info("project duplicate found, linking to existing",
|
||||||
|
"local_uuid", project.UUID,
|
||||||
|
"server_uuid", existing.UUID,
|
||||||
|
"server_id", existing.ID,
|
||||||
|
"code", project.Code,
|
||||||
|
"variant", project.Variant)
|
||||||
|
project.ID = existing.ID
|
||||||
|
} else {
|
||||||
|
// Not a duplicate issue, return original error
|
||||||
return fmt.Errorf("upsert project on server: %w", err)
|
return fmt.Errorf("upsert project on server: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local project with server ID
|
||||||
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||||
if localErr == nil {
|
if localErr == nil {
|
||||||
if project.ID > 0 {
|
if project.ID > 0 {
|
||||||
|
|||||||
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>
|
||||||
@@ -36,3 +36,6 @@ Implemented strict `lot_category` flow using `pricelist_items.lot_category` only
|
|||||||
Additional fixes (2026-02-11):
|
Additional fixes (2026-02-11):
|
||||||
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
|
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
|
||||||
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
|
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
|
||||||
|
|
||||||
|
UI rule (2026-02-19):
|
||||||
|
- In all breadcrumbs, truncate long specification/configuration names to 16 characters using ellipsis.
|
||||||
|
|||||||
66
releases/memory/v1.3.2.md
Normal file
66
releases/memory/v1.3.2.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Release v1.3.2 (2026-02-19)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Release focuses on stability and data integrity for local configurations. Added configuration revision history, stronger recovery for broken local sync/version states, improved sync self-healing, and clearer API error logging.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Configuration Revisions
|
||||||
|
|
||||||
|
- Added full local configuration revision flow with storage and UI support.
|
||||||
|
- Introduced revisions page/template and backend plumbing for browsing revisions.
|
||||||
|
- Prevented duplicate revisions when content did not actually change.
|
||||||
|
|
||||||
|
### Local Data Integrity and Recovery
|
||||||
|
|
||||||
|
- Added migration and snapshot support for local configuration version data.
|
||||||
|
- Hardened updates for legacy/orphaned configuration rows:
|
||||||
|
- allow update when project UUID is unchanged even if referenced project is missing locally;
|
||||||
|
- recover gracefully when `current_version_id` is stale or version rows are missing.
|
||||||
|
- Added regression tests for orphan-project and missing-current-version scenarios.
|
||||||
|
|
||||||
|
### Sync Reliability
|
||||||
|
|
||||||
|
- Added smart self-healing path for sync errors.
|
||||||
|
- Fixed duplicate-project sync edge cases.
|
||||||
|
|
||||||
|
### API and Logging
|
||||||
|
|
||||||
|
- Improved HTTP error mapping for configuration updates (`404/403` instead of generic `500` in known cases).
|
||||||
|
- Enhanced request logger to capture error responses (status, response body snippet, gin errors) for failed requests.
|
||||||
|
|
||||||
|
### UI and Export
|
||||||
|
|
||||||
|
- Updated project detail and index templates for revisions and related UX improvements.
|
||||||
|
- Updated export pipeline and tests to align with revisions/project behavior changes.
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
None identified.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- 24 files changed, 2394 insertions(+), 482 deletions(-)
|
||||||
|
- Main touched areas:
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/services/local_configuration.go`
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/services/local_configuration_versioning_test.go`
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/localdb/{localdb.go,migrations.go,snapshots.go,local_migrations_test.go}`
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/services/export.go`
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/cmd/qfs/main.go`
|
||||||
|
- `/Users/mchusavitin/Documents/git/QuoteForge/web/templates/{config_revisions.html,project_detail.html,index.html,base.html}`
|
||||||
|
|
||||||
|
## Commits Included (`v1.3.1..v1.3.2`)
|
||||||
|
|
||||||
|
- `b153afb` - Add smart self-healing for sync errors
|
||||||
|
- `8508ee2` - Fix sync errors for duplicate projects and add modal scrolling
|
||||||
|
- `2e973b6` - Add configuration revisions system and project variant deletion
|
||||||
|
- `71f73e2` - chore: save current changes
|
||||||
|
- `cbaeafa` - Deduplicate configuration revisions and update revisions UI
|
||||||
|
- `075fc70` - Harden local config updates and error logging
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- [x] Targeted tests for local configuration update/version recovery:
|
||||||
|
- `go test ./internal/services -run 'TestUpdateNoAuth(AllowsOrphanProjectWhenUUIDUnchanged|RecoversWhenCurrentVersionMissing|KeepsProjectWhenProjectUUIDOmitted)$'`
|
||||||
|
- [ ] Full regression suite not run in this release step.
|
||||||
@@ -45,10 +45,10 @@
|
|||||||
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||||||
|
|
||||||
<!-- Sync Info Modal -->
|
<!-- Sync Info Modal -->
|
||||||
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
|
||||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
|
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
|
||||||
<div class="p-6">
|
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center">
|
||||||
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
||||||
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
|
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -56,8 +56,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<div class="overflow-y-auto flex-1">
|
||||||
|
<div class="p-6 space-y-5">
|
||||||
<!-- Section 1: DB Connection -->
|
<!-- Section 1: DB Connection -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
|
<h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
|
||||||
@@ -123,9 +125,31 @@
|
|||||||
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
|
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
|
||||||
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 5: Self-Healing (shown only if errors exist) -->
|
||||||
|
<div id="modal-repair-section" class="hidden">
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<h4 class="font-medium text-blue-900 mb-2">Автоматическое исправление</h4>
|
||||||
|
<p class="text-sm text-blue-700 mb-3">
|
||||||
|
Система может исправить данные и очистить ошибки синхронизации:
|
||||||
|
</p>
|
||||||
|
<ul class="text-sm text-blue-700 mb-3 ml-4 list-disc space-y-1">
|
||||||
|
<li>Проверит и исправит названия проектов</li>
|
||||||
|
<li>Восстановит битые ссылки на проекты</li>
|
||||||
|
<li>Очистит ошибки и даст pending changes еще шанс</li>
|
||||||
|
</ul>
|
||||||
|
<button id="repair-button" onclick="repairPendingChanges()"
|
||||||
|
class="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
ИСПРАВИТЬ
|
||||||
|
</button>
|
||||||
|
<div id="repair-result" class="mt-2 text-sm hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="p-6 border-t border-gray-200 flex-shrink-0">
|
||||||
|
<div class="flex justify-end">
|
||||||
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
||||||
Закрыть
|
Закрыть
|
||||||
</button>
|
</button>
|
||||||
@@ -235,7 +259,8 @@
|
|||||||
// Section 4: Errors
|
// Section 4: Errors
|
||||||
const errorsSection = document.getElementById('modal-errors-section');
|
const errorsSection = document.getElementById('modal-errors-section');
|
||||||
const errorsList = document.getElementById('modal-errors-list');
|
const errorsList = document.getElementById('modal-errors-list');
|
||||||
if (data.errors && data.errors.length > 0) {
|
const hasErrors = data.errors && data.errors.length > 0;
|
||||||
|
if (hasErrors) {
|
||||||
errorsSection.classList.remove('hidden');
|
errorsSection.classList.remove('hidden');
|
||||||
errorsList.innerHTML = data.errors.map(error => {
|
errorsList.innerHTML = data.errors.map(error => {
|
||||||
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
||||||
@@ -246,12 +271,65 @@
|
|||||||
} else {
|
} else {
|
||||||
errorsSection.classList.add('hidden');
|
errorsSection.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section 5: Repair (show only if errors exist)
|
||||||
|
const repairSection = document.getElementById('modal-repair-section');
|
||||||
|
const repairResult = document.getElementById('repair-result');
|
||||||
|
if (hasErrors) {
|
||||||
|
repairSection.classList.remove('hidden');
|
||||||
|
repairResult.classList.add('hidden');
|
||||||
|
repairResult.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
repairSection.classList.add('hidden');
|
||||||
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Failed to load sync info:', e);
|
console.error('Failed to load sync info:', e);
|
||||||
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repair pending changes
|
||||||
|
async function repairPendingChanges() {
|
||||||
|
const button = document.getElementById('repair-button');
|
||||||
|
const resultDiv = document.getElementById('repair-result');
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Исправление...';
|
||||||
|
resultDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sync/repair', { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
if (data.repaired > 0) {
|
||||||
|
resultDiv.className = 'mt-2 text-sm text-green-700 bg-green-50 rounded px-3 py-2';
|
||||||
|
resultDiv.textContent = `✓ Исправлено: ${data.repaired}`;
|
||||||
|
// Reload sync info after repair
|
||||||
|
setTimeout(() => loadSyncInfo(), 1000);
|
||||||
|
} else {
|
||||||
|
resultDiv.className = 'mt-2 text-sm text-yellow-700 bg-yellow-50 rounded px-3 py-2';
|
||||||
|
resultDiv.textContent = 'Нечего исправлять или проблемы остались';
|
||||||
|
if (data.remaining_errors && data.remaining_errors.length > 0) {
|
||||||
|
resultDiv.innerHTML += '<div class="mt-1 text-xs">' + data.remaining_errors.join('<br>') + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
||||||
|
resultDiv.textContent = 'Ошибка: ' + (data.error || 'неизвестная ошибка');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
||||||
|
resultDiv.textContent = 'Ошибка запроса: ' + e.message;
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = 'ИСПРАВИТЬ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Event delegation for sync dropdown and actions
|
// Event delegation for sync dropdown and actions
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadDBUser();
|
loadDBUser();
|
||||||
|
|||||||
238
web/templates/config_revisions.html
Normal file
238
web/templates/config_revisions.html
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
{{define "title"}}Ревизии - QuoteForge{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="text-2xl font-bold flex items-center gap-2" id="revisions-breadcrumbs">
|
||||||
|
<a id="breadcrumb-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-code">—</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<a id="breadcrumb-variant-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-variant">main</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<a id="breadcrumb-config-link" href="/configurator" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-config">—</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<span class="text-gray-600">Ревизии</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="revisions-list">
|
||||||
|
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const configUUID = '{{.ConfigUUID}}';
|
||||||
|
let configData = null;
|
||||||
|
let projectData = null;
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text || '';
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return '—';
|
||||||
|
return '$\u00A0' + num.toLocaleString('ru-RU', {minimumFractionDigits: 0, maximumFractionDigits: 2});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersionSnapshot(version) {
|
||||||
|
try {
|
||||||
|
const raw = typeof version.data === 'string' ? version.data : '';
|
||||||
|
if (!raw) return { article: '—', price: null, serverCount: 1 };
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
article: parsed.article || '—',
|
||||||
|
price: typeof parsed.total_price === 'number' ? parsed.total_price : null,
|
||||||
|
serverCount: Number.isFinite(Number(parsed.server_count)) && Number(parsed.server_count) > 0
|
||||||
|
? Number(parsed.server_count)
|
||||||
|
: 1
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return { article: '—', price: null, serverCount: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateBreadcrumbSpecName(name) {
|
||||||
|
const maxLength = 16;
|
||||||
|
if (!name || name.length <= maxLength) return name;
|
||||||
|
return name.slice(0, maxLength - 1) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfigInfo() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID);
|
||||||
|
if (!resp.ok) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Конфигурация не найдена</div>';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
configData = await resp.json();
|
||||||
|
|
||||||
|
const fullConfigName = configData.name || 'Конфигурация';
|
||||||
|
const configBreadcrumbEl = document.getElementById('breadcrumb-config');
|
||||||
|
configBreadcrumbEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||||
|
configBreadcrumbEl.title = fullConfigName;
|
||||||
|
document.getElementById('breadcrumb-config-link').href = '/configurator?uuid=' + configUUID;
|
||||||
|
|
||||||
|
if (configData.project_uuid) {
|
||||||
|
const projResp = await fetch('/api/projects/' + configData.project_uuid);
|
||||||
|
if (projResp.ok) {
|
||||||
|
projectData = await projResp.json();
|
||||||
|
document.getElementById('breadcrumb-code').textContent = projectData.code || '—';
|
||||||
|
const variant = (projectData.variant || '').trim();
|
||||||
|
document.getElementById('breadcrumb-variant').textContent = variant === '' ? 'main' : variant;
|
||||||
|
document.getElementById('breadcrumb-variant-link').href = '/projects/' + projectData.uuid;
|
||||||
|
|
||||||
|
// Find main variant for code link
|
||||||
|
const allResp = await fetch('/api/projects/all');
|
||||||
|
if (allResp.ok) {
|
||||||
|
const allProjects = await allResp.json();
|
||||||
|
const main = (Array.isArray(allProjects) ? allProjects : []).find(
|
||||||
|
p => p.code === projectData.code && (p.variant || '').trim() === ''
|
||||||
|
);
|
||||||
|
if (main) {
|
||||||
|
document.getElementById('breadcrumb-code-link').href = '/projects/' + main.uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVersions() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID + '/versions?limit=200');
|
||||||
|
if (!resp.ok) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки ревизий</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
const versions = data.versions || [];
|
||||||
|
renderVersions(versions);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVersions(versions) {
|
||||||
|
if (versions.length === 0) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет ревизий</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||||
|
html += '<thead class="bg-gray-50"><tr>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Серверов</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||||
|
html += '</tr></thead><tbody class="divide-y">';
|
||||||
|
|
||||||
|
versions.forEach((v, idx) => {
|
||||||
|
const date = new Date(v.created_at).toLocaleString('ru-RU');
|
||||||
|
const author = v.created_by || '—';
|
||||||
|
const snapshot = parseVersionSnapshot(v);
|
||||||
|
const isCurrent = idx === 0;
|
||||||
|
|
||||||
|
html += '<tr class="hover:bg-gray-50' + (isCurrent ? ' bg-blue-50' : '') + '">';
|
||||||
|
html += '<td class="px-4 py-3 text-sm font-medium">';
|
||||||
|
html += 'v' + v.version_no;
|
||||||
|
if (isCurrent) html += ' <span class="text-xs text-blue-600 font-normal">(текущая)</span>';
|
||||||
|
html += '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(date) + '</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">' + escapeHtml(snapshot.article) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + formatMoney(snapshot.price) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(String(snapshot.serverCount)) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||||
|
|
||||||
|
// Open in configurator (readonly view)
|
||||||
|
html += '<a href="/configurator?uuid=' + configUUID + '&version=' + v.version_no + '" class="text-blue-600 hover:text-blue-800 inline-block" title="Открыть">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg></a>';
|
||||||
|
|
||||||
|
// Clone from this version
|
||||||
|
html += '<button onclick="cloneFromVersion(' + v.version_no + ')" class="text-green-600 hover:text-green-800" title="Скопировать из этой ревизии">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
|
||||||
|
|
||||||
|
// Rollback (not for current version)
|
||||||
|
if (!isCurrent) {
|
||||||
|
html += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-800" title="Восстановить эту ревизию">';
|
||||||
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path></svg></button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
document.getElementById('revisions-list').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneFromVersion(versionNo) {
|
||||||
|
const name = prompt('Название копии:', (configData ? configData.name : '') + ' (v' + versionNo + ')');
|
||||||
|
if (!name) return;
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID + '/clone', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name: name, from_version: versionNo})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Не удалось скопировать');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await resp.json();
|
||||||
|
showToast('Копия создана', 'success');
|
||||||
|
if (created && created.uuid) {
|
||||||
|
window.location.href = '/configurator?uuid=' + created.uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollbackToVersion(versionNo) {
|
||||||
|
if (!confirm('Восстановить конфигурацию до ревизии v' + versionNo + '?')) return;
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID + '/rollback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({target_version: versionNo})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Не удалось восстановить');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('Ревизия восстановлена', 'success');
|
||||||
|
loadVersions();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
|
const ok = await loadConfigInfo();
|
||||||
|
if (!ok) return;
|
||||||
|
await loadVersions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
@@ -20,7 +20,9 @@
|
|||||||
<span id="breadcrumb-project-variant">main</span>
|
<span id="breadcrumb-project-variant">main</span>
|
||||||
</a>
|
</a>
|
||||||
<span class="text-gray-400">-</span>
|
<span class="text-gray-400">-</span>
|
||||||
|
<a id="breadcrumb-config-name-link" href="#" class="text-blue-700 hover:underline">
|
||||||
<span id="breadcrumb-config-name">Конфигуратор</span>
|
<span id="breadcrumb-config-name">Конфигуратор</span>
|
||||||
|
</a>
|
||||||
<span class="text-gray-400">-</span>
|
<span class="text-gray-400">-</span>
|
||||||
<span id="breadcrumb-config-version">v1</span>
|
<span id="breadcrumb-config-version">v1</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,6 +340,7 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
|||||||
// State
|
// State
|
||||||
let configUUID = '{{.ConfigUUID}}';
|
let configUUID = '{{.ConfigUUID}}';
|
||||||
let configName = '';
|
let configName = '';
|
||||||
|
let currentVersionNo = 1;
|
||||||
let projectUUID = '';
|
let projectUUID = '';
|
||||||
let projectName = '';
|
let projectName = '';
|
||||||
let projectCode = '';
|
let projectCode = '';
|
||||||
@@ -398,14 +401,28 @@ function updateConfigBreadcrumbs() {
|
|||||||
}
|
}
|
||||||
codeEl.textContent = code;
|
codeEl.textContent = code;
|
||||||
variantEl.textContent = variant;
|
variantEl.textContent = variant;
|
||||||
configEl.textContent = configName || 'Конфигурация';
|
const fullConfigName = configName || 'Конфигурация';
|
||||||
versionEl.textContent = 'v1';
|
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||||
|
configEl.title = fullConfigName;
|
||||||
|
versionEl.textContent = 'v' + (currentVersionNo || 1);
|
||||||
|
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||||
|
if (configNameLinkEl && configUUID) {
|
||||||
|
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateBreadcrumbSpecName(name) {
|
||||||
|
const maxLength = 16;
|
||||||
|
if (!name || name.length <= maxLength) return name;
|
||||||
|
return name.slice(0, maxLength - 1) + '…';
|
||||||
}
|
}
|
||||||
let currentTab = 'base';
|
let currentTab = 'base';
|
||||||
let allComponents = [];
|
let allComponents = [];
|
||||||
let cart = [];
|
let cart = [];
|
||||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||||
|
let hasUnsavedChanges = false;
|
||||||
|
let exitSaveStarted = false;
|
||||||
let serverCount = 1; // Server count for the configuration
|
let serverCount = 1; // Server count for the configuration
|
||||||
let serverModelForQuote = '';
|
let serverModelForQuote = '';
|
||||||
let supportCode = '';
|
let supportCode = '';
|
||||||
@@ -688,6 +705,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
|
|
||||||
const config = await resp.json();
|
const config = await resp.json();
|
||||||
configName = config.name;
|
configName = config.name;
|
||||||
|
currentVersionNo = config.current_version_no || 1;
|
||||||
projectUUID = config.project_uuid || '';
|
projectUUID = config.project_uuid || '';
|
||||||
await loadProjectIndex();
|
await loadProjectIndex();
|
||||||
updateConfigBreadcrumbs();
|
updateConfigBreadcrumbs();
|
||||||
@@ -1882,13 +1900,128 @@ function getCurrentArticle() {
|
|||||||
return currentArticle || '';
|
return currentArticle || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAutosaveStorageKey() {
|
||||||
|
return `qf_config_autosave_${configUUID || 'default'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSavePayload() {
|
||||||
|
const customPriceInput = document.getElementById('custom-price-input');
|
||||||
|
const customPriceValue = parseFloat(customPriceInput.value);
|
||||||
|
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: configName,
|
||||||
|
items: cart,
|
||||||
|
custom_price: customPrice,
|
||||||
|
notes: '',
|
||||||
|
server_count: serverCount,
|
||||||
|
server_model: serverModelForQuote,
|
||||||
|
support_code: supportCode,
|
||||||
|
article: getCurrentArticle(),
|
||||||
|
pricelist_id: selectedPricelistIds.estimate,
|
||||||
|
only_in_stock: onlyInStock
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAutosaveDraft() {
|
||||||
|
if (!configUUID) return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(getAutosaveStorageKey(), JSON.stringify({
|
||||||
|
payload: buildSavePayload(),
|
||||||
|
saved_at: Date.now()
|
||||||
|
}));
|
||||||
|
} catch (_) {
|
||||||
|
// ignore storage failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAutosaveDraft() {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(getAutosaveStorageKey());
|
||||||
|
} catch (_) {
|
||||||
|
// ignore storage failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreAutosaveDraftIfAny() {
|
||||||
|
if (!configUUID) return;
|
||||||
|
let raw = null;
|
||||||
|
try {
|
||||||
|
raw = sessionStorage.getItem(getAutosaveStorageKey());
|
||||||
|
} catch (_) {
|
||||||
|
raw = null;
|
||||||
|
}
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const payload = parsed && parsed.payload ? parsed.payload : null;
|
||||||
|
if (!payload) return;
|
||||||
|
|
||||||
|
if (Array.isArray(payload.items)) {
|
||||||
|
cart = payload.items.map(item => ({
|
||||||
|
lot_name: item.lot_name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unit_price,
|
||||||
|
estimate_price: item.unit_price,
|
||||||
|
warehouse_price: null,
|
||||||
|
competitor_price: null,
|
||||||
|
description: item.description || '',
|
||||||
|
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (typeof payload.server_count === 'number' && payload.server_count > 0) {
|
||||||
|
serverCount = payload.server_count;
|
||||||
|
const serverCountInput = document.getElementById('server-count');
|
||||||
|
if (serverCountInput) serverCountInput.value = serverCount;
|
||||||
|
const totalServerCount = document.getElementById('total-server-count');
|
||||||
|
if (totalServerCount) totalServerCount.textContent = serverCount;
|
||||||
|
}
|
||||||
|
serverModelForQuote = payload.server_model || serverModelForQuote;
|
||||||
|
supportCode = payload.support_code || supportCode;
|
||||||
|
currentArticle = payload.article || currentArticle;
|
||||||
|
selectedPricelistIds.estimate = payload.pricelist_id || selectedPricelistIds.estimate;
|
||||||
|
onlyInStock = Boolean(payload.only_in_stock);
|
||||||
|
|
||||||
|
const customPriceInput = document.getElementById('custom-price-input');
|
||||||
|
if (customPriceInput) {
|
||||||
|
if (typeof payload.custom_price === 'number' && payload.custom_price > 0) {
|
||||||
|
customPriceInput.value = payload.custom_price.toFixed(2);
|
||||||
|
} else {
|
||||||
|
customPriceInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
} catch (_) {
|
||||||
|
// ignore invalid draft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfigOnExit() {
|
||||||
|
if (!configUUID || !hasUnsavedChanges || exitSaveStarted) return;
|
||||||
|
exitSaveStarted = true;
|
||||||
|
try {
|
||||||
|
fetch('/api/configs/' + configUUID, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(buildSavePayload()),
|
||||||
|
keepalive: true
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// best effort save on page exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function triggerAutoSave() {
|
function triggerAutoSave() {
|
||||||
// Debounce autosave - wait 1 second after last change
|
// Autosave keeps local draft only; server revision is created on Save/Exit.
|
||||||
|
hasUnsavedChanges = true;
|
||||||
if (autoSaveTimeout) {
|
if (autoSaveTimeout) {
|
||||||
clearTimeout(autoSaveTimeout);
|
clearTimeout(autoSaveTimeout);
|
||||||
}
|
}
|
||||||
autoSaveTimeout = setTimeout(() => {
|
autoSaveTimeout = setTimeout(() => {
|
||||||
saveConfig(false); // false = don't show notification
|
persistAutosaveDraft();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1902,32 +2035,13 @@ async function saveConfig(showNotification = true) {
|
|||||||
|
|
||||||
await refreshPriceLevels({ force: true, noCache: true });
|
await refreshPriceLevels({ force: true, noCache: true });
|
||||||
|
|
||||||
// Get custom price if set
|
|
||||||
const customPriceInput = document.getElementById('custom-price-input');
|
|
||||||
const customPriceValue = parseFloat(customPriceInput.value);
|
|
||||||
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
|
||||||
|
|
||||||
// Get server count
|
|
||||||
const serverCountValue = serverCount;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/configs/' + configUUID, {
|
const resp = await fetch('/api/configs/' + configUUID, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(buildSavePayload())
|
||||||
name: configName,
|
|
||||||
items: cart,
|
|
||||||
custom_price: customPrice,
|
|
||||||
notes: '',
|
|
||||||
server_count: serverCountValue,
|
|
||||||
server_model: serverModelForQuote,
|
|
||||||
support_code: supportCode,
|
|
||||||
article: getCurrentArticle(),
|
|
||||||
pricelist_id: selectedPricelistIds.estimate,
|
|
||||||
only_in_stock: onlyInStock
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@@ -1937,6 +2051,16 @@ async function saveConfig(showNotification = true) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saved = await resp.json();
|
||||||
|
if (saved && saved.current_version_no) {
|
||||||
|
currentVersionNo = saved.current_version_no;
|
||||||
|
const versionEl = document.getElementById('breadcrumb-config-version');
|
||||||
|
if (versionEl) versionEl.textContent = 'v' + currentVersionNo;
|
||||||
|
}
|
||||||
|
hasUnsavedChanges = false;
|
||||||
|
clearAutosaveDraft();
|
||||||
|
exitSaveStarted = false;
|
||||||
|
|
||||||
if (showNotification) {
|
if (showNotification) {
|
||||||
showToast('Сохранено', 'success');
|
showToast('Сохранено', 'success');
|
||||||
}
|
}
|
||||||
@@ -1973,7 +2097,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-4 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,12 @@
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
||||||
@@ -108,38 +114,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="rename-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="config-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Переименовать квоту</h2>
|
<h2 class="text-xl font-semibold mb-4">Действия с квотой</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Новое название</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||||
<input type="text" id="rename-input"
|
<input type="text" id="config-action-name"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
<input type="hidden" id="rename-uuid">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
<div class="flex justify-end space-x-3 mt-6">
|
<input type="checkbox" id="config-action-copy" class="rounded border-gray-300">
|
||||||
<button onclick="closeRenameModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
Создать копию
|
||||||
<button onclick="renameConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
</label>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="clone-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
|
||||||
<h2 class="text-xl font-semibold mb-4">Копировать квоту</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название копии</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||||
<input type="text" id="clone-input"
|
<input id="config-action-project-input"
|
||||||
|
list="config-action-project-options"
|
||||||
|
placeholder="Начните вводить проект"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
<input type="hidden" id="clone-uuid">
|
<datalist id="config-action-project-options"></datalist>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
|
||||||
|
<input id="config-action-variant-input"
|
||||||
|
list="config-action-variant-options"
|
||||||
|
placeholder="Выберите вариант"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<datalist id="config-action-variant-options"></datalist>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="config-action-uuid">
|
||||||
|
<input type="hidden" id="config-action-current-name">
|
||||||
|
<input type="hidden" id="config-action-current-project">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3 mt-6">
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
<button onclick="closeCloneModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
<button onclick="closeConfigActionModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||||
<button onclick="cloneConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Копировать</button>
|
<button onclick="saveConfigAction()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,6 +212,7 @@ let configStatusMode = 'active';
|
|||||||
let project = null;
|
let project = null;
|
||||||
let allConfigs = [];
|
let allConfigs = [];
|
||||||
let projectVariants = [];
|
let projectVariants = [];
|
||||||
|
let projectsCatalog = [];
|
||||||
let variantMenuInitialized = false;
|
let variantMenuInitialized = false;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
@@ -234,14 +245,18 @@ function normalizeVariantLabel(variant) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadVariantsForCode(code) {
|
async function loadVariantsForCode(code) {
|
||||||
if (!code) return;
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/projects/all');
|
const resp = await fetch('/api/projects/all');
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
projectsCatalog = allProjects.filter(p => p && p.uuid && p.is_active !== false);
|
||||||
|
if (!code) {
|
||||||
|
projectVariants = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
projectVariants = allProjects
|
projectVariants = allProjects
|
||||||
.filter(p => (p.code || '').trim() === code)
|
.filter(p => (p.code || '').trim() === code && p.is_active !== false)
|
||||||
.map(p => ({uuid: p.uuid, variant: (p.variant || '').trim()}));
|
.map(p => ({uuid: p.uuid, variant: (p.variant || '').trim()}));
|
||||||
projectVariants.sort((a, b) => normalizeVariantLabel(a.variant).localeCompare(normalizeVariantLabel(b.variant)));
|
projectVariants.sort((a, b) => normalizeVariantLabel(a.variant).localeCompare(normalizeVariantLabel(b.variant)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -341,6 +356,7 @@ function renderConfigs(configs) {
|
|||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
|
||||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||||
|
html += '<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Ревизия</th>';
|
||||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||||
html += '</tr></thead><tbody class="divide-y">';
|
html += '</tr></thead><tbody class="divide-y">';
|
||||||
|
|
||||||
@@ -361,17 +377,23 @@ 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;
|
||||||
|
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">';
|
||||||
if (configStatusMode === 'archived') {
|
if (configStatusMode === 'archived') {
|
||||||
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg></button>';
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg></button>';
|
||||||
} else {
|
} else {
|
||||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
html += '<a href="/configs/' + c.uuid + '/revisions" class="text-purple-600 hover:text-purple-800 inline-block" title="Ревизии">';
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg></a>';
|
||||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
html += '<button onclick="openConfigActionModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\', \'' + (c.project_uuid || projectUUID) + '\')" class="text-indigo-600 hover:text-indigo-800" title="Переименовать / копировать / переместить">';
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg></button>';
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h16"></path></svg></button>';
|
||||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
|
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
|
||||||
}
|
}
|
||||||
@@ -383,7 +405,8 @@ 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 += '</tr>';
|
html += '</tr>';
|
||||||
html += '</tfoot>';
|
html += '</tfoot>';
|
||||||
@@ -540,68 +563,227 @@ async function reactivateConfig(uuid) {
|
|||||||
loadConfigs();
|
loadConfigs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRenameModal(uuid, currentName) {
|
function projectCodeEntries() {
|
||||||
document.getElementById('rename-uuid').value = uuid;
|
const byCode = new Map();
|
||||||
document.getElementById('rename-input').value = currentName;
|
projectsCatalog.forEach(p => {
|
||||||
document.getElementById('rename-modal').classList.remove('hidden');
|
const code = (p.code || '').trim();
|
||||||
document.getElementById('rename-modal').classList.add('flex');
|
if (!code || byCode.has(code)) return;
|
||||||
}
|
byCode.set(code, {
|
||||||
|
code: code,
|
||||||
function closeRenameModal() {
|
name: (p.name || '').trim()
|
||||||
document.getElementById('rename-modal').classList.add('hidden');
|
|
||||||
document.getElementById('rename-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renameConfig() {
|
|
||||||
const uuid = document.getElementById('rename-uuid').value;
|
|
||||||
const name = document.getElementById('rename-input').value.trim();
|
|
||||||
if (!name) {
|
|
||||||
alert('Введите название');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resp = await fetch('/api/configs/' + uuid + '/rename', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({name: name})
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
});
|
||||||
alert('Не удалось переименовать');
|
return Array.from(byCode.values()).sort((a, b) => a.code.localeCompare(b.code, 'ru'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProjectAutocompleteValue(entry) {
|
||||||
|
if (!entry) return '';
|
||||||
|
return entry.name ? (entry.code + ' - ' + entry.name) : entry.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProjectCodeFromInput(rawInput) {
|
||||||
|
const input = (rawInput || '').trim();
|
||||||
|
if (!input) return '';
|
||||||
|
const entries = projectCodeEntries();
|
||||||
|
|
||||||
|
const exactCode = entries.find(e => e.code.toLowerCase() === input.toLowerCase());
|
||||||
|
if (exactCode) return exactCode.code;
|
||||||
|
|
||||||
|
const exactDisplayMatches = entries.filter(e => formatProjectAutocompleteValue(e).toLowerCase() === input.toLowerCase());
|
||||||
|
if (exactDisplayMatches.length === 1) return exactDisplayMatches[0].code;
|
||||||
|
|
||||||
|
const byUniqueName = entries.filter(e => (e.name || '').toLowerCase() === input.toLowerCase());
|
||||||
|
if (byUniqueName.length === 1) return byUniqueName[0].code;
|
||||||
|
|
||||||
|
if (input.includes(' - ')) {
|
||||||
|
const codeCandidate = input.split(' - ')[0].trim();
|
||||||
|
const byCandidate = entries.find(e => e.code.toLowerCase() === codeCandidate.toLowerCase());
|
||||||
|
if (byCandidate) return byCandidate.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateProjectAutocomplete() {
|
||||||
|
const options = document.getElementById('config-action-project-options');
|
||||||
|
options.innerHTML = '';
|
||||||
|
projectCodeEntries().forEach(entry => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = formatProjectAutocompleteValue(entry);
|
||||||
|
opt.label = entry.code;
|
||||||
|
options.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantsForProjectCode(projectCode) {
|
||||||
|
const code = (projectCode || '').trim();
|
||||||
|
if (!code) return [];
|
||||||
|
return projectsCatalog
|
||||||
|
.filter(p => (p.code || '').trim() === code)
|
||||||
|
.map(p => ({uuid: p.uuid, variant: normalizeVariantLabel(p.variant || '')}))
|
||||||
|
.sort((a, b) => a.variant.localeCompare(b.variant, 'ru'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateVariantAutocomplete(projectCode, selectedVariantLabel) {
|
||||||
|
const options = document.getElementById('config-action-variant-options');
|
||||||
|
const input = document.getElementById('config-action-variant-input');
|
||||||
|
const variants = variantsForProjectCode(projectCode);
|
||||||
|
options.innerHTML = '';
|
||||||
|
variants.forEach(v => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = v.variant;
|
||||||
|
options.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (selectedVariantLabel) {
|
||||||
|
input.value = selectedVariantLabel;
|
||||||
|
} else if (variants.length === 1) {
|
||||||
|
input.value = variants[0].variant;
|
||||||
|
} else {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTargetProjectUUIDFromInputs() {
|
||||||
|
const projectCode = resolveProjectCodeFromInput(document.getElementById('config-action-project-input').value);
|
||||||
|
if (!projectCode) {
|
||||||
|
return {error: 'Выберите проект из подсказок'};
|
||||||
|
}
|
||||||
|
const variantLabel = normalizeVariantLabel(document.getElementById('config-action-variant-input').value || 'main');
|
||||||
|
const target = projectsCatalog.find(p =>
|
||||||
|
(p.code || '').trim() === projectCode &&
|
||||||
|
normalizeVariantLabel(p.variant || '') === variantLabel
|
||||||
|
);
|
||||||
|
if (!target) {
|
||||||
|
return {error: 'Выберите вариант из подсказок'};
|
||||||
|
}
|
||||||
|
return {uuid: target.uuid};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActionModalMode() {
|
||||||
|
const copyCheckbox = document.getElementById('config-action-copy');
|
||||||
|
if (copyCheckbox.checked) {
|
||||||
|
// no-op: copy always uses latest revision
|
||||||
|
} else {
|
||||||
|
// no-op: copy always uses latest revision
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfigActionModal(uuid, currentName, currentProjectUUID) {
|
||||||
|
document.getElementById('config-action-uuid').value = uuid;
|
||||||
|
document.getElementById('config-action-current-name').value = currentName;
|
||||||
|
document.getElementById('config-action-current-project').value = currentProjectUUID || projectUUID;
|
||||||
|
document.getElementById('config-action-name').value = currentName;
|
||||||
|
document.getElementById('config-action-copy').checked = false;
|
||||||
|
populateProjectAutocomplete();
|
||||||
|
const currentProject = projectsCatalog.find(p => p.uuid === (currentProjectUUID || projectUUID));
|
||||||
|
if (currentProject) {
|
||||||
|
const entry = {
|
||||||
|
code: (currentProject.code || '').trim(),
|
||||||
|
name: (currentProject.name || '').trim()
|
||||||
|
};
|
||||||
|
document.getElementById('config-action-project-input').value = formatProjectAutocompleteValue(entry);
|
||||||
|
populateVariantAutocomplete(entry.code, normalizeVariantLabel(currentProject.variant || ''));
|
||||||
|
} else {
|
||||||
|
document.getElementById('config-action-project-input').value = '';
|
||||||
|
populateVariantAutocomplete('', '');
|
||||||
|
}
|
||||||
|
syncActionModalMode();
|
||||||
|
document.getElementById('config-action-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('config-action-modal').classList.add('flex');
|
||||||
|
const nameInput = document.getElementById('config-action-name');
|
||||||
|
nameInput.focus();
|
||||||
|
nameInput.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfigActionModal() {
|
||||||
|
document.getElementById('config-action-modal').classList.add('hidden');
|
||||||
|
document.getElementById('config-action-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfigAction() {
|
||||||
|
const notify = (message, type) => {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(message, type || 'success');
|
||||||
|
} else {
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uuid = document.getElementById('config-action-uuid').value;
|
||||||
|
const currentName = document.getElementById('config-action-current-name').value;
|
||||||
|
const currentProjectUUID = document.getElementById('config-action-current-project').value || projectUUID;
|
||||||
|
const name = document.getElementById('config-action-name').value.trim();
|
||||||
|
const copy = document.getElementById('config-action-copy').checked;
|
||||||
|
const targetProject = resolveTargetProjectUUIDFromInputs();
|
||||||
|
if (targetProject.error) {
|
||||||
|
notify(targetProject.error, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeRenameModal();
|
const targetProjectUUID = targetProject.uuid || currentProjectUUID;
|
||||||
loadConfigs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCloneModal(uuid, currentName) {
|
|
||||||
document.getElementById('clone-uuid').value = uuid;
|
|
||||||
document.getElementById('clone-input').value = currentName + ' (копия)';
|
|
||||||
document.getElementById('clone-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('clone-modal').classList.add('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCloneModal() {
|
|
||||||
document.getElementById('clone-modal').classList.add('hidden');
|
|
||||||
document.getElementById('clone-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cloneConfig() {
|
|
||||||
const uuid = document.getElementById('clone-uuid').value;
|
|
||||||
const name = document.getElementById('clone-input').value.trim();
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
alert('Введите название');
|
notify('Введите название', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs/' + uuid + '/clone', {
|
|
||||||
|
if (copy) {
|
||||||
|
const cloneResp = await fetch('/api/projects/' + targetProjectUUID + '/configs/' + uuid + '/clone', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({name: name})
|
body: JSON.stringify({name: name})
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!cloneResp.ok) {
|
||||||
alert('Не удалось скопировать');
|
notify('Не удалось скопировать квоту', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeCloneModal();
|
closeConfigActionModal();
|
||||||
loadConfigs();
|
await loadConfigs();
|
||||||
|
notify('Копия создана', 'success');
|
||||||
|
if (targetProjectUUID && targetProjectUUID !== projectUUID) {
|
||||||
|
window.location.href = '/projects/' + targetProjectUUID;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
if (name !== currentName) {
|
||||||
|
const renameResp = await fetch('/api/configs/' + uuid + '/rename', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name: name})
|
||||||
|
});
|
||||||
|
if (!renameResp.ok) {
|
||||||
|
notify('Не удалось переименовать квоту', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetProjectUUID !== currentProjectUUID) {
|
||||||
|
const moveResp = await fetch('/api/configs/' + uuid + '/project', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({project_uuid: targetProjectUUID})
|
||||||
|
});
|
||||||
|
if (!moveResp.ok) {
|
||||||
|
notify('Не удалось перенести квоту', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
closeConfigActionModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeConfigActionModal();
|
||||||
|
await loadConfigs();
|
||||||
|
notify('Изменения сохранены', 'success');
|
||||||
|
if (targetProjectUUID && targetProjectUUID !== projectUUID) {
|
||||||
|
window.location.href = '/projects/' + targetProjectUUID;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImportModal() {
|
function openImportModal() {
|
||||||
@@ -757,26 +939,120 @@ function wildcardMatch(value, pattern) {
|
|||||||
return regex.test(value);
|
return regex.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteVariant() {
|
||||||
|
if (!project) return;
|
||||||
|
const variantLabel = normalizeVariantLabel(project.variant);
|
||||||
|
if (!confirm('Удалить вариант «' + variantLabel + '»? Все квоты будут архивированы.')) return;
|
||||||
|
const resp = await fetch('/api/projects/' + projectUUID, {method: 'DELETE'});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Не удалось удалить вариант');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Redirect to main variant or projects list
|
||||||
|
const mainVariant = projectVariants.find(v => normalizeVariantLabel(v.variant) === 'main');
|
||||||
|
if (mainVariant && mainVariant.uuid !== projectUUID) {
|
||||||
|
window.location.href = '/projects/' + mainVariant.uuid;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/projects';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDeleteVariantButton() {
|
||||||
|
const btn = document.getElementById('delete-variant-btn');
|
||||||
|
if (!btn || !project) return;
|
||||||
|
if ((project.variant || '').trim() !== '') {
|
||||||
|
btn.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
btn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
||||||
document.getElementById('rename-modal').addEventListener('click', function(e) { if (e.target === this) closeRenameModal(); });
|
|
||||||
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
|
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
|
||||||
document.getElementById('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); });
|
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
|
||||||
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
|
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
|
||||||
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
|
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
|
||||||
|
document.getElementById('config-action-project-input').addEventListener('input', function(e) {
|
||||||
|
const code = resolveProjectCodeFromInput(e.target.value);
|
||||||
|
populateVariantAutocomplete(code, '');
|
||||||
|
});
|
||||||
|
document.getElementById('config-action-copy').addEventListener('change', function(e) {
|
||||||
|
const currentName = document.getElementById('config-action-current-name').value;
|
||||||
|
const nameInput = document.getElementById('config-action-name');
|
||||||
|
if (e.target.checked && nameInput.value.trim() === currentName.trim()) {
|
||||||
|
nameInput.value = currentName + ' (копия)';
|
||||||
|
}
|
||||||
|
syncActionModalMode();
|
||||||
|
});
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeCreateModal();
|
closeCreateModal();
|
||||||
closeRenameModal();
|
closeConfigActionModal();
|
||||||
closeCloneModal();
|
|
||||||
closeImportModal();
|
closeImportModal();
|
||||||
closeProjectSettingsModal();
|
closeProjectSettingsModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
updateDeleteVariantButton();
|
||||||
await loadConfigs();
|
await loadConfigs();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user