20 Commits

Author SHA1 Message Date
Mikhail Chusavitin
b27152b353 configs: save pending template changes 2026-02-06 16:43:04 +03:00
Mikhail Chusavitin
2e69089bd5 projects: add tracker_url and project create modal 2026-02-06 16:42:32 +03:00
Mikhail Chusavitin
be1c962fec Add tracker link on project detail page 2026-02-06 16:31:34 +03:00
Mikhail Chusavitin
57215cb7b3 Fix local pricelist uniqueness and preserve config project on update 2026-02-06 16:00:23 +03:00
Mikhail Chusavitin
31dce9c721 Make full sync push pending and pull projects/configurations 2026-02-06 15:25:07 +03:00
Mikhail Chusavitin
06d0e8b14b Prepare v1.0.3 release notes 2026-02-06 14:04:06 +03:00
Mikhail Chusavitin
b1b50ce2ef Add projects table controls and sync status tab with app version 2026-02-06 14:02:21 +03:00
Mikhail Chusavitin
6ab1e9899e sync: recover missing server config during update push 2026-02-06 13:41:01 +03:00
Mikhail Chusavitin
a1d21927a3 Fix MySQL DSN escaping for setup passwords and clarify DB user setup 2026-02-06 13:27:57 +03:00
Mikhail Chusavitin
a90c07c879 update stale files list 2026-02-06 13:03:59 +03:00
Mikhail Chusavitin
e9307c4bad Apply remaining pricelist and local-first updates 2026-02-06 13:01:40 +03:00
Mikhail Chusavitin
1b48401828 Use admin price-refresh logic for pricelist recalculation 2026-02-06 13:00:27 +03:00
Mikhail Chusavitin
4a86f7b7ba fix: skip startup sql migrations when not needed or no permissions 2026-02-06 11:56:55 +03:00
Mikhail Chusavitin
955467fbea feat: add projects flow and consolidate default project handling 2026-02-06 11:39:12 +03:00
Mikhail Chusavitin
9ddffe48e9 Update pricelist repository, service, and tests 2026-02-06 10:14:24 +03:00
Mikhail Chusavitin
4732605925 Enforce pricelist write checks and auto-restart on DB settings change 2026-02-05 15:44:54 +03:00
Mikhail Chusavitin
d318a7f462 Purge orphan sync queue entries before push 2026-02-05 15:17:06 +03:00
Mikhail Chusavitin
1bec110d91 Handle stale configuration sync events when local row is missing 2026-02-05 15:11:43 +03:00
Mikhail Chusavitin
6392e4b4a9 Drop qt_users dependency for configs and track app version 2026-02-05 15:07:23 +03:00
Mikhail Chusavitin
8f7defdb8a Добавил шаблон для создания пользователя в БД 2026-02-05 10:55:02 +03:00
40 changed files with 2557 additions and 224 deletions

19
.gitignore vendored
View File

@@ -16,6 +16,25 @@ config.yaml
# Local Go build cache used in sandboxed runs
.gocache/
# Local tooling state
.claude/
# Editor settings
.idea/
.vscode/
*.swp
*.swo
# Temp and logs
*.tmp
*.temp
*.log
# Go test/build artifacts
*.out
*.test
coverage/
# ---> macOS
# General
.DS_Store

View File

@@ -113,30 +113,52 @@ go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
```sql
-- 1) Создать (или оставить существующего) пользователя
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
-- 1) Создать пользователя (если его ещё нет)
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
-- 2) Сбросить лишние права (без пересоздания пользователя)
-- 2) Если пользователь уже существовал, принудительно обновить пароль
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
-- 3) (Опционально, но рекомендуется) удалить дубли пользователя с другими host,
-- чтобы не возникало конфликтов вида user@localhost vs user@'%'
DROP USER IF EXISTS 'quote_user'@'localhost';
DROP USER IF EXISTS 'quote_user'@'127.0.0.1';
DROP USER IF EXISTS 'quote_user'@'::1';
-- 4) Сбросить лишние права
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
-- 3) Чтение данных для конфигуратора и синка
-- 5) Чтение данных для конфигуратора и синка
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
-- 4) Работа с конфигурациями
-- 6) Работа с конфигурациями
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
FLUSH PRIVILEGES;
SHOW GRANTS FOR 'quote_user'@'%';
SHOW CREATE USER 'quote_user'@'%';
```
Полный набор прав для пользователя квотаций:
```sql
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
```
Важно:
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
- если используется host-специфичный аккаунт (`'quote_user'@'192.168.x.x'`), назначьте права и для него;
- если видите ошибку `Access denied for user ...@'<ip>'`, проверьте, что не осталось других записей `quote_user@host` кроме `quote_user@'%'`;
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
### 4. Импорт метаданных компонентов

View File

@@ -81,4 +81,4 @@ func main() {
log.Println(" - reset-counters: Reset usage counters")
log.Println(" - update-popularity: Update popularity scores")
}
}
}

View File

@@ -7,12 +7,14 @@ import (
"fmt"
"io/fs"
"log/slog"
"math"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"syscall"
@@ -42,6 +44,8 @@ import (
// Version is set via ldflags during build
var Version = "dev"
const backgroundSyncInterval = 5 * time.Minute
func main() {
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
@@ -167,11 +171,30 @@ func main() {
// Always apply SQL migrations on startup when database is available.
// This keeps schema in sync for long-running installations without manual steps.
// If current DB user does not have enough privileges, continue startup in normal mode.
if mariaDB != nil {
sqlMigrationsPath := filepath.Join("migrations")
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err)
os.Exit(1)
needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath)
if err != nil {
if models.IsMigrationPermissionError(err) {
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
} else {
slog.Error("startup SQL migrations check failed", "path", sqlMigrationsPath, "error", err)
os.Exit(1)
}
} else if needsMigrations {
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
if models.IsMigrationPermissionError(err) {
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
} else {
slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err)
os.Exit(1)
}
} else {
slog.Info("startup SQL migrations applied", "path", sqlMigrationsPath)
}
} else {
slog.Debug("startup SQL migrations not needed", "path", sqlMigrationsPath)
}
}
@@ -188,7 +211,7 @@ func main() {
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute)
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
go syncWorker.Start(workerCtx)
srv := &http.Server{
@@ -460,7 +483,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
exportService = services.NewExportService(cfg.Export, categoryRepo)
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo)
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
} else {
// In offline mode, we still need to create services that don't require DB
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
@@ -468,7 +491,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
quoteService = services.NewQuoteService(nil, nil, pricingService)
exportService = services.NewExportService(cfg.Export, nil)
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(nil, nil, nil)
pricelistService = pricelist.NewService(nil, nil, nil, nil)
}
// isOnline function for local-first architecture
@@ -504,44 +527,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if !connMgr.IsOnline() {
return
}
serverDB, err := connMgr.GetDB()
if err != nil || serverDB == nil {
return
}
projectRepo := repository.NewProjectRepository(serverDB)
serverProjects, _, err := projectRepo.List(0, 10000, true)
if err != nil {
return
}
now := time.Now()
for i := range serverProjects {
sp := serverProjects[i]
localProject, getErr := local.GetProjectByUUID(sp.UUID)
if getErr == nil && localProject != nil {
// Keep unsynced local changes intact.
if localProject.SyncStatus == "pending" {
continue
}
localProject.OwnerUsername = sp.OwnerUsername
localProject.Name = sp.Name
localProject.IsActive = sp.IsActive
localProject.IsSystem = sp.IsSystem
localProject.CreatedAt = sp.CreatedAt
localProject.UpdatedAt = sp.UpdatedAt
serverID := sp.ID
localProject.ServerID = &serverID
localProject.SyncStatus = "synced"
localProject.SyncedAt = &now
_ = local.SaveProject(localProject)
continue
}
lp := localdb.ProjectToLocal(&sp)
lp.SyncStatus = "synced"
lp.SyncedAt = &now
_ = local.SaveProject(lp)
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
slog.Warn("failed to sync projects from server", "error", err)
}
}
@@ -561,7 +548,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
}
@@ -721,6 +708,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricelists.GET("/:id", pricelistHandler.Get)
pricelists.GET("/:id/items", pricelistHandler.GetItems)
pricelists.POST("", pricelistHandler.Create)
pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
pricelists.DELETE("/:id", pricelistHandler.Delete)
}
@@ -1020,10 +1009,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
status := c.DefaultQuery("status", "active")
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
author := strings.ToLower(strings.TrimSpace(c.Query("author")))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "10"))
sortField := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sort", "created_at")))
sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc")))
if status != "active" && status != "archived" && status != "all" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
return
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 10
}
if perPage > 100 {
perPage = 100
}
if sortField != "name" && sortField != "created_at" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort field"})
return
}
if sortDir != "asc" && sortDir != "desc" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort direction"})
return
}
allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil {
@@ -1043,12 +1054,69 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
continue
}
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
continue
}
filtered = append(filtered, p)
}
projectRows := make([]gin.H, 0, len(filtered))
for i := range filtered {
p := filtered[i]
sort.Slice(filtered, func(i, j int) bool {
left := filtered[i]
right := filtered[j]
if sortField == "name" {
leftName := strings.ToLower(strings.TrimSpace(left.Name))
rightName := strings.ToLower(strings.TrimSpace(right.Name))
if leftName == rightName {
if sortDir == "asc" {
return left.CreatedAt.Before(right.CreatedAt)
}
return left.CreatedAt.After(right.CreatedAt)
}
if sortDir == "asc" {
return leftName < rightName
}
return leftName > rightName
}
if left.CreatedAt.Equal(right.CreatedAt) {
leftName := strings.ToLower(strings.TrimSpace(left.Name))
rightName := strings.ToLower(strings.TrimSpace(right.Name))
if sortDir == "asc" {
return leftName < rightName
}
return leftName > rightName
}
if sortDir == "asc" {
return left.CreatedAt.Before(right.CreatedAt)
}
return left.CreatedAt.After(right.CreatedAt)
})
total := len(filtered)
totalPages := 0
if total > 0 {
totalPages = int(math.Ceil(float64(total) / float64(perPage)))
}
if totalPages > 0 && page > totalPages {
page = totalPages
}
start := (page - 1) * perPage
if start < 0 {
start = 0
}
end := start + perPage
if end > total {
end = total
}
paged := []models.Project{}
if start < total {
paged = filtered[start:end]
}
projectRows := make([]gin.H, 0, len(paged))
for i := range paged {
p := paged[i]
configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active")
if err != nil {
configs = &services.ProjectConfigurationsResult{
@@ -1062,6 +1130,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
"uuid": p.UUID,
"owner_username": p.OwnerUsername,
"name": p.Name,
"tracker_url": p.TrackerURL,
"is_active": p.IsActive,
"is_system": p.IsSystem,
"created_at": p.CreatedAt,
@@ -1072,10 +1141,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
c.JSON(http.StatusOK, gin.H{
"projects": projectRows,
"status": status,
"search": search,
"total": len(projectRows),
"projects": projectRows,
"status": status,
"search": search,
"author": author,
"sort": sortField,
"dir": sortDir,
"page": page,
"per_page": perPage,
"total": total,
"total_pages": totalPages,
})
})
@@ -1250,6 +1325,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
{
syncAPI.GET("/status", syncHandler.GetStatus)
syncAPI.GET("/info", syncHandler.GetInfo)
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/all", syncHandler.SyncAll)

View File

@@ -2,9 +2,12 @@ package config
import (
"fmt"
"net"
"os"
"strconv"
"time"
mysqlDriver "github.com/go-sql-driver/mysql"
"gopkg.in/yaml.v3"
)
@@ -39,8 +42,18 @@ type DatabaseConfig struct {
}
func (d *DatabaseConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
d.User, d.Password, d.Host, d.Port, d.Name)
cfg := mysqlDriver.NewConfig()
cfg.User = d.User
cfg.Passwd = d.Password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
cfg.DBName = d.Name
cfg.ParseTime = true
cfg.Loc = time.Local
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return cfg.FormatDSN()
}
type AuthConfig struct {

View File

@@ -1,12 +1,13 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"github.com/gin-gonic/gin"
)
type PricelistHandler struct {
@@ -22,8 +23,19 @@ func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *
func (h *PricelistHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
activeOnly := c.DefaultQuery("active_only", "false") == "true"
pricelists, total, err := h.service.List(page, perPage)
var (
pricelists any
total int64
err error
)
if activeOnly {
pricelists, total, err = h.service.ListActive(page, perPage)
} else {
pricelists, total, err = h.service.List(page, perPage)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -111,6 +123,74 @@ func (h *PricelistHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, pl)
}
// CreateWithProgress creates a pricelist and streams progress updates over SSE.
func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
createdBy := h.localDB.GetDBUser()
if createdBy == "" {
createdBy = "unknown"
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
pl, err := h.service.CreateFromCurrentPrices(createdBy)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, pl)
return
}
sendProgress := func(payload gin.H) {
c.SSEvent("progress", payload)
flusher.Flush()
}
sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."})
pl, err := h.service.CreateFromCurrentPricesWithProgress(createdBy, func(p pricelist.CreateProgress) {
sendProgress(gin.H{
"current": p.Current,
"total": p.Total,
"status": p.Status,
"message": p.Message,
"updated": p.Updated,
"errors": p.Errors,
"lot_name": p.LotName,
})
})
if err != nil {
sendProgress(gin.H{
"current": 0,
"total": 4,
"status": "error",
"message": fmt.Sprintf("Ошибка: %v", err),
})
return
}
sendProgress(gin.H{
"current": 4,
"total": 4,
"status": "completed",
"message": "Готово",
"pricelist": pl,
})
}
// Delete deletes a pricelist by ID
func (h *PricelistHandler) Delete(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
@@ -137,6 +217,40 @@ func (h *PricelistHandler) Delete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
}
// SetActive toggles active flag on a pricelist.
func (h *PricelistHandler) SetActive(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
var req struct {
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.SetActive(uint(id), req.IsActive); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive})
}
// GetItems returns items for a pricelist with pagination
func (h *PricelistHandler) GetItems(c *gin.Context) {
idStr := c.Param("id")

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"html/template"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
@@ -13,8 +14,9 @@ import (
qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
@@ -93,10 +95,9 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
}
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
user, password, host, port, database)
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
@@ -169,10 +170,9 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
}
// Test connection first
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
user, password, host, port, database)
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
@@ -254,3 +254,19 @@ func testWritePermission(db *gorm.DB) bool {
return true
}
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
cfg := mysqlDriver.NewConfig()
cfg.User = user
cfg.Passwd = password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(host, strconv.Itoa(port))
cfg.DBName = database
cfg.ParseTime = true
cfg.Loc = time.Local
cfg.Timeout = timeout
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return cfg.FormatDSN()
}

View File

@@ -17,14 +17,16 @@ import (
// SyncHandler handles sync API endpoints
type SyncHandler struct {
localDB *localdb.LocalDB
syncService *sync.Service
connMgr *db.ConnectionManager
tmpl *template.Template
localDB *localdb.LocalDB
syncService *sync.Service
connMgr *db.ConnectionManager
autoSyncInterval time.Duration
onlineGraceFactor float64
tmpl *template.Template
}
// NewSyncHandler creates a new sync handler
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) {
// Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
var tmpl *template.Template
@@ -39,10 +41,12 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
}
return &SyncHandler{
localDB: localDB,
syncService: syncService,
connMgr: connMgr,
tmpl: tmpl,
localDB: localDB,
syncService: syncService,
connMgr: connMgr,
autoSyncInterval: autoSyncInterval,
onlineGraceFactor: 1.10,
tmpl: tmpl,
}, nil
}
@@ -173,18 +177,28 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
Synced: synced,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// SyncAllResponse represents result of full sync
type SyncAllResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"`
Duration string `json:"duration"`
Success bool `json:"success"`
Message string `json:"message"`
PendingPushed int `json:"pending_pushed"`
ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"`
ProjectsImported int `json:"projects_imported"`
ProjectsUpdated int `json:"projects_updated"`
ProjectsSkipped int `json:"projects_skipped"`
ConfigurationsImported int `json:"configurations_imported"`
ConfigurationsUpdated int `json:"configurations_updated"`
ConfigurationsSkipped int `json:"configurations_skipped"`
Duration string `json:"duration"`
}
// SyncAll syncs both components and pricelists
// SyncAll performs full bidirectional sync:
// - push pending local changes (projects/configurations) to server
// - pull components, pricelists, projects, and configurations from server
// POST /api/sync/all
func (h *SyncHandler) SyncAll(c *gin.Context) {
if !h.checkOnline() {
@@ -196,7 +210,18 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
}
startTime := time.Now()
var componentsSynced, pricelistsSynced int
var pendingPushed, componentsSynced, pricelistsSynced int
// Push local pending changes first (projects/configurations)
pendingPushed, err := h.syncService.PushPendingChanges()
if err != nil {
slog.Error("pending push failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Pending changes push failed: " + err.Error(),
})
return
}
// Sync components
mariaDB, err := h.connMgr.GetDB()
@@ -226,18 +251,56 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Pricelist sync failed: " + err.Error(),
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
})
return
}
projectsResult, err := h.syncService.ImportProjectsToLocal()
if err != nil {
slog.Error("project import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Project import failed: " + err.Error(),
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced,
})
return
}
configsResult, err := h.syncService.ImportConfigurationsToLocal()
if err != nil {
slog.Error("configuration import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Configuration import failed: " + err.Error(),
"pending_pushed": pendingPushed,
"components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced,
"projects_imported": projectsResult.Imported,
"projects_updated": projectsResult.Updated,
"projects_skipped": projectsResult.Skipped,
})
return
}
c.JSON(http.StatusOK, SyncAllResponse{
Success: true,
Message: "Full sync completed successfully",
ComponentsSynced: componentsSynced,
PricelistsSynced: pricelistsSynced,
Duration: time.Since(startTime).String(),
Success: true,
Message: "Full sync completed successfully",
PendingPushed: pendingPushed,
ComponentsSynced: componentsSynced,
PricelistsSynced: pricelistsSynced,
ProjectsImported: projectsResult.Imported,
ProjectsUpdated: projectsResult.Updated,
ProjectsSkipped: projectsResult.Skipped,
ConfigurationsImported: configsResult.Imported,
ConfigurationsUpdated: configsResult.Updated,
ConfigurationsSkipped: configsResult.Skipped,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// checkOnline checks if MariaDB is accessible
@@ -273,6 +336,7 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
Synced: pushed,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// GetPendingCount returns the number of pending changes
@@ -308,6 +372,14 @@ type SyncInfoResponse struct {
Errors []SyncError `json:"errors,omitempty"`
}
type SyncUsersStatusResponse struct {
IsOnline bool `json:"is_online"`
AutoSyncIntervalSeconds int64 `json:"auto_sync_interval_seconds"`
OnlineThresholdSeconds int64 `json:"online_threshold_seconds"`
GeneratedAt time.Time `json:"generated_at"`
Users []sync.UserSyncStatus `json:"users"`
}
// SyncError represents a sync error
type SyncError struct {
Timestamp time.Time `json:"timestamp"`
@@ -364,6 +436,43 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
})
}
// GetUsersStatus returns last sync timestamps for users with sync heartbeats.
// GET /api/sync/users-status
func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
threshold := time.Duration(float64(h.autoSyncInterval) * h.onlineGraceFactor)
isOnline := h.checkOnline()
if !isOnline {
c.JSON(http.StatusOK, SyncUsersStatusResponse{
IsOnline: false,
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
OnlineThresholdSeconds: int64(threshold.Seconds()),
GeneratedAt: time.Now().UTC(),
Users: []sync.UserSyncStatus{},
})
return
}
// Keep current client heartbeat fresh so app version is available in the table.
h.syncService.RecordSyncHeartbeat()
users, err := h.syncService.ListUserSyncStatuses(threshold)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, SyncUsersStatusResponse{
IsOnline: true,
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
OnlineThresholdSeconds: int64(threshold.Seconds()),
GeneratedAt: time.Now().UTC(),
Users: users,
})
}
// SyncStatusPartial renders the sync status partial for htmx
// GET /partials/sync-status
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {

View File

@@ -28,6 +28,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
PricelistID: cfg.PricelistID,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
@@ -70,6 +71,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
Notes: local.Notes,
IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount,
PricelistID: local.PricelistID,
PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt,
}
@@ -97,6 +99,7 @@ func ProjectToLocal(project *models.Project) *LocalProject {
UUID: project.UUID,
OwnerUsername: project.OwnerUsername,
Name: project.Name,
TrackerURL: project.TrackerURL,
IsActive: project.IsActive,
IsSystem: project.IsSystem,
CreatedAt: project.CreatedAt,
@@ -115,6 +118,7 @@ func LocalToProject(local *LocalProject) *models.Project {
UUID: local.UUID,
OwnerUsername: local.OwnerUsername,
Name: local.Name,
TrackerURL: local.TrackerURL,
IsActive: local.IsActive,
IsSystem: local.IsSystem,
CreatedAt: local.CreatedAt,

View File

@@ -3,6 +3,7 @@ package localdb
import (
"path/filepath"
"testing"
"time"
)
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
@@ -70,3 +71,57 @@ func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
t.Fatalf("expected local migrations to be recorded")
}
}
func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "pricelist_index_fix.db")
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&LocalPricelist{
ServerID: 10,
Version: "2026-02-06-001",
Name: "v1",
CreatedAt: time.Now().Add(-time.Hour),
SyncedAt: time.Now().Add(-time.Hour),
}); err != nil {
t.Fatalf("save first pricelist: %v", err)
}
if err := local.DB().Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_version_legacy
ON local_pricelists(version)
`).Error; err != nil {
t.Fatalf("create legacy unique version index: %v", err)
}
if err := local.DB().Where("id = ?", "2026_02_06_pricelist_index_fix").
Delete(&LocalSchemaMigration{}).Error; err != nil {
t.Fatalf("delete migration record: %v", err)
}
if err := runLocalMigrations(local.DB()); err != nil {
t.Fatalf("rerun local migrations: %v", err)
}
if err := local.SaveLocalPricelist(&LocalPricelist{
ServerID: 11,
Version: "2026-02-06-001",
Name: "v1-duplicate-version",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save second pricelist with duplicate version: %v", err)
}
var count int64
if err := local.DB().Model(&LocalPricelist{}).Count(&count).Error; err != nil {
t.Fatalf("count pricelists: %v", err)
}
if count != 2 {
t.Fatalf("expected 2 pricelists, got %d", count)
}
}

View File

@@ -4,15 +4,19 @@ import (
"errors"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
"github.com/glebarez/sqlite"
mysqlDriver "github.com/go-sql-driver/mysql"
uuidpkg "github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
@@ -141,19 +145,23 @@ func (l *LocalDB) GetDSN() (string, error) {
return "", err
}
// Add aggressive timeouts for offline-first architecture
// timeout: connection establishment timeout (3s)
// readTimeout: I/O read timeout (3s)
// writeTimeout: I/O write timeout (3s)
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s",
settings.User,
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
settings.Host,
settings.Port,
settings.Database,
)
cfg := mysqlDriver.NewConfig()
cfg.User = settings.User
cfg.Passwd = settings.PasswordEncrypted // Contains decrypted password after GetSettings
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
cfg.DBName = settings.Database
cfg.ParseTime = true
cfg.Loc = time.Local
// Add aggressive timeouts for offline-first architecture.
cfg.Timeout = 3 * time.Second
cfg.ReadTimeout = 3 * time.Second
cfg.WriteTimeout = 3 * time.Second
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return dsn, nil
return cfg.FormatDSN(), nil
}
// DB returns the underlying gorm.DB for advanced operations
@@ -530,6 +538,15 @@ func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, e
return &pricelist, nil
}
// GetLocalPricelistByVersion returns a local pricelist by version string.
func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistByID returns a local pricelist by its local ID
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
var pricelist LocalPricelist
@@ -541,7 +558,16 @@ func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
// SaveLocalPricelist saves a pricelist to local SQLite
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
return l.db.Save(pricelist).Error
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "server_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"version": pricelist.Version,
"name": pricelist.Name,
"created_at": pricelist.CreatedAt,
"synced_at": pricelist.SyncedAt,
"is_used": pricelist.IsUsed,
}),
}).Create(pricelist).Error
}
// GetLocalPricelists returns all local pricelists
@@ -605,6 +631,25 @@ func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
Update("is_used", isUsed).Error
}
// RecalculateAllLocalPricelistUsage refreshes local_pricelists.is_used based on active configurations.
func (l *LocalDB) RecalculateAllLocalPricelistUsage() error {
return l.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
return err
}
return tx.Exec(`
UPDATE local_pricelists
SET is_used = 1
WHERE server_id IN (
SELECT DISTINCT pricelist_id
FROM local_configurations
WHERE pricelist_id IS NOT NULL AND is_active = 1
)
`).Error
})
}
// DeleteLocalPricelist deletes a pricelist and its items
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
// Delete items first

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
@@ -42,6 +43,16 @@ var localMigrations = []localMigration{
name: "Create default projects and attach existing configurations",
run: backfillProjectsForConfigurations,
},
{
id: "2026_02_06_pricelist_backfill",
name: "Attach existing configurations to latest local pricelist and recalc usage",
run: backfillConfigurationPricelists,
},
{
id: "2026_02_06_pricelist_index_fix",
name: "Use unique server_id for local pricelists and allow duplicate versions",
run: fixLocalPricelistIndexes,
},
}
func runLocalMigrations(db *gorm.DB) error {
@@ -192,9 +203,92 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
return &project, nil
}
func backfillConfigurationPricelists(tx *gorm.DB) error {
var latest LocalPricelist
if err := tx.Order("created_at DESC").First(&latest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("load latest local pricelist: %w", err)
}
if err := tx.Model(&LocalConfiguration{}).
Where("pricelist_id IS NULL").
Update("pricelist_id", latest.ServerID).Error; err != nil {
return fmt.Errorf("backfill configuration pricelist_id: %w", err)
}
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
return fmt.Errorf("reset local pricelist usage flags: %w", err)
}
if err := tx.Exec(`
UPDATE local_pricelists
SET is_used = 1
WHERE server_id IN (
SELECT DISTINCT pricelist_id
FROM local_configurations
WHERE pricelist_id IS NOT NULL AND is_active = 1
)
`).Error; err != nil {
return fmt.Errorf("recalculate local pricelist usage flags: %w", err)
}
return nil
}
func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
if candidate.IsZero() {
return fallback
}
return candidate
}
func fixLocalPricelistIndexes(tx *gorm.DB) error {
type indexRow struct {
Name string `gorm:"column:name"`
Unique int `gorm:"column:unique"`
}
var indexes []indexRow
if err := tx.Raw("PRAGMA index_list('local_pricelists')").Scan(&indexes).Error; err != nil {
return fmt.Errorf("list local_pricelists indexes: %w", err)
}
for _, idx := range indexes {
if idx.Unique == 0 {
continue
}
type indexInfoRow struct {
Name string `gorm:"column:name"`
}
var info []indexInfoRow
if err := tx.Raw(fmt.Sprintf("PRAGMA index_info('%s')", strings.ReplaceAll(idx.Name, "'", "''"))).Scan(&info).Error; err != nil {
return fmt.Errorf("load index info for %s: %w", idx.Name, err)
}
if len(info) != 1 || info[0].Name != "version" {
continue
}
quoted := strings.ReplaceAll(idx.Name, `"`, `""`)
if err := tx.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, quoted)).Error; err != nil {
return fmt.Errorf("drop unique version index %s: %w", idx.Name, err)
}
}
if err := tx.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_server_id
ON local_pricelists(server_id)
`).Error; err != nil {
return fmt.Errorf("ensure unique index local_pricelists(server_id): %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelists_version
ON local_pricelists(version)
`).Error; err != nil {
return fmt.Errorf("ensure index local_pricelists(version): %w", err)
}
return nil
}

View File

@@ -72,6 +72,7 @@ type LocalConfiguration struct {
Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -93,6 +94,7 @@ type LocalProject struct {
ServerID *uint `json:"server_id,omitempty"`
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
Name string `gorm:"not null" json:"name"`
TrackerURL string `json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"`
CreatedAt time.Time `json:"created_at"`
@@ -125,8 +127,8 @@ func (LocalConfigurationVersion) TableName() string {
// LocalPricelist stores cached pricelists from server
type LocalPricelist struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
Version string `gorm:"uniqueIndex;not null" json:"version"`
ServerID uint `gorm:"not null;uniqueIndex" json:"server_id"` // ID on MariaDB server
Version string `gorm:"not null;index" json:"version"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
SyncedAt time.Time `json:"synced_at"`

View File

@@ -22,6 +22,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"notes": localCfg.Notes,
"is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount,
"pricelist_id": localCfg.PricelistID,
"price_updated_at": localCfg.PriceUpdatedAt,
"created_at": localCfg.CreatedAt,
"updated_at": localCfg.UpdatedAt,
@@ -50,6 +51,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
OriginalUserID uint `json:"original_user_id"`
OriginalUsername string `json:"original_username"`
@@ -74,6 +76,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes: snapshot.Notes,
IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount,
PricelistID: snapshot.PricelistID,
PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername,

View File

@@ -53,6 +53,7 @@ type Configuration struct {
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`

View File

@@ -7,6 +7,7 @@ type Project struct {
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
Name string `gorm:"size:200;not null" json:"name"`
TrackerURL string `gorm:"size:500" json:"tracker_url"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
IsSystem bool `gorm:"default:false;index" json:"is_system"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`

View File

@@ -2,6 +2,7 @@ package models
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
@@ -9,6 +10,7 @@ import (
"strings"
"time"
mysqlDriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
)
@@ -22,6 +24,30 @@ func (SQLSchemaMigration) TableName() string {
return "qt_schema_migrations"
}
// NeedsSQLMigrations reports whether at least one SQL migration from migrationsDir
// is not yet recorded in qt_schema_migrations.
func NeedsSQLMigrations(db *gorm.DB, migrationsDir string) (bool, error) {
files, err := listSQLMigrationFiles(migrationsDir)
if err != nil {
return false, err
}
if len(files) == 0 {
return false, nil
}
// If tracking table does not exist yet, migrations are required.
if !db.Migrator().HasTable(&SQLSchemaMigration{}) {
return true, nil
}
var count int64
if err := db.Model(&SQLSchemaMigration{}).Where("filename IN ?", files).Count(&count).Error; err != nil {
return false, fmt.Errorf("check applied migrations: %w", err)
}
return count < int64(len(files)), nil
}
// RunSQLMigrations applies SQL files from migrationsDir once and records them in qt_schema_migrations.
// Local SQLite-only scripts are skipped automatically.
func RunSQLMigrations(db *gorm.DB, migrationsDir string) error {
@@ -29,27 +55,11 @@ func RunSQLMigrations(db *gorm.DB, migrationsDir string) error {
return fmt.Errorf("migrate qt_schema_migrations table: %w", err)
}
entries, err := os.ReadDir(migrationsDir)
files, err := listSQLMigrationFiles(migrationsDir)
if err != nil {
return fmt.Errorf("read migrations dir %s: %w", migrationsDir, err)
return err
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
continue
}
if isSQLiteOnlyMigration(name) {
continue
}
files = append(files, name)
}
sort.Strings(files)
for _, filename := range files {
var count int64
if err := db.Model(&SQLSchemaMigration{}).Where("filename = ?", filename).Count(&count).Error; err != nil {
@@ -84,6 +94,37 @@ func RunSQLMigrations(db *gorm.DB, migrationsDir string) error {
return nil
}
// IsMigrationPermissionError returns true if err indicates insufficient privileges
// to create/alter/read migration metadata or target schema objects.
func IsMigrationPermissionError(err error) bool {
if err == nil {
return false
}
var mysqlErr *mysqlDriver.MySQLError
if errors.As(err, &mysqlErr) {
switch mysqlErr.Number {
case 1044, 1045, 1142, 1143, 1227:
return true
}
}
lower := strings.ToLower(err.Error())
patterns := []string{
"command denied to user",
"access denied for user",
"permission denied",
"insufficient privilege",
"sqlstate 42000",
}
for _, pattern := range patterns {
if strings.Contains(lower, pattern) {
return true
}
}
return false
}
func ensureSQLMigrationsTable(db *gorm.DB) error {
stmt := `
CREATE TABLE IF NOT EXISTS qt_schema_migrations (
@@ -157,3 +198,30 @@ func splitSQLStatements(script string) []string {
}
return stmts
}
func listSQLMigrationFiles(migrationsDir string) ([]string, error) {
entries, err := os.ReadDir(migrationsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("read migrations dir %s: %w", migrationsDir, err)
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
continue
}
if isSQLiteOnlyMigration(name) {
continue
}
files = append(files, name)
}
sort.Strings(files)
return files, nil
}

View File

@@ -110,6 +110,10 @@ func (r *ComponentRepository) Update(component *models.LotMetadata) error {
return r.db.Save(component).Error
}
func (r *ComponentRepository) DB() *gorm.DB {
return r.db
}
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
return r.db.Create(component).Error
}

View File

@@ -64,6 +64,7 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
for i, pl := range pricelists {
var itemCount int64
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
usageCount, _ := r.CountUsage(pl.ID)
summaries[i] = models.PricelistSummary{
ID: pl.ID,
@@ -72,7 +73,7 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
CreatedAt: pl.CreatedAt,
CreatedBy: pl.CreatedBy,
IsActive: pl.IsActive,
UsageCount: pl.UsageCount,
UsageCount: int(usageCount),
ExpiresAt: pl.ExpiresAt,
ItemCount: itemCount,
}
@@ -92,6 +93,9 @@ func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
var itemCount int64
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
pricelist.ItemCount = int(itemCount)
if usageCount, err := r.CountUsage(id); err == nil {
pricelist.UsageCount = int(usageCount)
}
return &pricelist, nil
}
@@ -132,13 +136,13 @@ func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
// Delete deletes a pricelist if usage_count is 0
func (r *PricelistRepository) Delete(id uint) error {
pricelist, err := r.GetByID(id)
usageCount, err := r.CountUsage(id)
if err != nil {
return err
}
if pricelist.UsageCount > 0 {
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount)
if usageCount > 0 {
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount)
}
// Delete items first
@@ -208,6 +212,20 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
return items, total, nil
}
// GetPriceForLot returns item price for a lot within a pricelist.
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item models.PricelistItem
if err := r.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).First(&item).Error; err != nil {
return 0, err
}
return item.Price, nil
}
// SetActive toggles active flag on a pricelist.
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error
}
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
func (r *PricelistRepository) GenerateVersion() (string, error) {
today := time.Now().Format("2006-01-02")
@@ -295,6 +313,15 @@ func (r *PricelistRepository) DecrementUsageCount(id uint) error {
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error
}
// CountUsage returns number of configurations referencing pricelist.
func (r *PricelistRepository) CountUsage(id uint) (int64, error) {
var count int64
if err := r.db.Table("qt_configurations").Where("pricelist_id = ?", id).Count(&count).Error; err != nil {
return 0, fmt.Errorf("counting configurations for pricelist %d: %w", id, err)
}
return count, nil
}
// GetExpiredUnused returns pricelists that are expired and unused
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
var pricelists []models.Pricelist

View File

@@ -3,6 +3,7 @@ package repository
import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ProjectRepository struct {
@@ -21,6 +22,30 @@ func (r *ProjectRepository) Update(project *models.Project) error {
return r.db.Save(project).Error
}
func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
if err := r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.AssignmentColumns([]string{
"owner_username",
"name",
"tracker_url",
"is_active",
"is_system",
"updated_at",
}),
}).Create(project).Error; err != nil {
return err
}
// Ensure caller always gets canonical server ID.
var persisted models.Project
if err := r.db.Where("uuid = ?", project.UUID).First(&persisted).Error; err != nil {
return err
}
project.ID = persisted.ID
return nil
}
func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) {
var project models.Project
if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {

View File

@@ -24,6 +24,7 @@ type ConfigurationService struct {
configRepo *repository.ConfigurationRepository
projectRepo *repository.ProjectRepository
componentRepo *repository.ComponentRepository
pricelistRepo *repository.PricelistRepository
quoteService *QuoteService
}
@@ -31,12 +32,14 @@ func NewConfigurationService(
configRepo *repository.ConfigurationRepository,
projectRepo *repository.ProjectRepository,
componentRepo *repository.ComponentRepository,
pricelistRepo *repository.PricelistRepository,
quoteService *QuoteService,
) *ConfigurationService {
return &ConfigurationService{
configRepo: configRepo,
projectRepo: projectRepo,
componentRepo: componentRepo,
pricelistRepo: pricelistRepo,
quoteService: quoteService,
}
}
@@ -49,6 +52,7 @@ type CreateConfigRequest struct {
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id,omitempty"`
}
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
@@ -56,6 +60,10 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
@@ -75,6 +83,7 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
PricelistID: pricelistID,
}
if err := s.configRepo.Create(config); err != nil {
@@ -115,6 +124,10 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
@@ -131,6 +144,7 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.PricelistID = pricelistID
if err := s.configRepo.Update(config); err != nil {
return nil, err
@@ -207,6 +221,7 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
}
if err := s.configRepo.Create(clone); err != nil {
@@ -261,6 +276,10 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
if req.ServerCount > 1 {
@@ -275,6 +294,7 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.PricelistID = pricelistID
if err := s.configRepo.Update(config); err != nil {
return nil, err
@@ -341,6 +361,7 @@ func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName s
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
}
if err := s.configRepo.Create(clone); err != nil {
@@ -370,6 +391,23 @@ func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectU
return &project.UUID, nil
}
func (s *ConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
if s.pricelistRepo == nil {
return pricelistID, nil
}
if pricelistID != nil && *pricelistID > 0 {
if _, err := s.pricelistRepo.GetByID(*pricelistID); err != nil {
return nil, err
}
return pricelistID, nil
}
latest, err := s.pricelistRepo.GetLatestActive()
if err != nil {
return nil, nil
}
return &latest.ID, nil
}
// RefreshPricesNoAuth refreshes prices without ownership check
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
@@ -377,8 +415,30 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
return nil, ErrConfigNotFound
}
var latestPricelistID *uint
if s.pricelistRepo != nil {
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
latestPricelistID = &pl.ID
}
}
updatedItems := make(models.ConfigItems, len(config.Items))
for i, item := range config.Items {
if latestPricelistID != nil {
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
if s.componentRepo == nil {
updatedItems[i] = item
continue
}
metadata, err := s.componentRepo.GetByLotName(item.LotName)
if err != nil || metadata.CurrentPrice == nil {
updatedItems[i] = item
@@ -399,6 +459,9 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
}
config.TotalPrice = &total
if latestPricelistID != nil {
config.PricelistID = latestPricelistID
}
now := time.Now()
config.PriceUpdatedAt = &now
@@ -432,10 +495,32 @@ func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string)
return nil, ErrConfigForbidden
}
var latestPricelistID *uint
if s.pricelistRepo != nil {
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
latestPricelistID = &pl.ID
}
}
// Update prices for all items
updatedItems := make(models.ConfigItems, len(config.Items))
for i, item := range config.Items {
if latestPricelistID != nil {
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
// Get current component price
if s.componentRepo == nil {
updatedItems[i] = item
continue
}
metadata, err := s.componentRepo.GetByLotName(item.LotName)
if err != nil || metadata.CurrentPrice == nil {
// Keep original item if component not found or no price available
@@ -461,6 +546,9 @@ func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string)
}
config.TotalPrice = &total
if latestPricelistID != nil {
config.PricelistID = latestPricelistID
}
// Set price update timestamp
now := time.Now()

View File

@@ -59,6 +59,10 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
if req.ServerCount > 1 {
@@ -76,6 +80,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
PricelistID: pricelistID,
CreatedAt: time.Now(),
}
@@ -124,7 +129,14 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
return nil, ErrConfigForbidden
}
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
projectUUID := localCfg.ProjectUUID
if req.ProjectUUID != nil {
projectUUID, err = s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
@@ -150,6 +162,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount
localCfg.PricelistID = pricelistID
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
@@ -254,6 +267,7 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
CreatedAt: time.Now(),
}
@@ -324,10 +338,28 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
return nil, ErrConfigForbidden
}
// Refresh local pricelists when online and use latest active/local pricelist for recalculation.
if s.isOnline() {
_ = s.syncService.SyncPricelistsIfNeeded()
}
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
// Get current component price from local cache
if latestErr == nil && latestPricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
if err == nil && price > 0 {
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
// Fallback to current component price from local cache
component, err := s.localDB.GetLocalComponent(item.LotName)
if err != nil || component.CurrentPrice == nil {
// Keep original item if component not found or no price available
@@ -353,6 +385,9 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
}
localCfg.TotalPrice = &total
if latestErr == nil && latestPricelist != nil {
localCfg.PricelistID = &latestPricelist.ServerID
}
// Set price update timestamp and mark for sync
now := time.Now()
@@ -386,7 +421,14 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
return nil, ErrConfigNotFound
}
projectUUID, err := s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
projectUUID := localCfg.ProjectUUID
if req.ProjectUUID != nil {
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
@@ -411,6 +453,7 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount
localCfg.PricelistID = pricelistID
localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending"
@@ -502,6 +545,7 @@ func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newN
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
CreatedAt: time.Now(),
}
@@ -640,10 +684,27 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
return nil, ErrConfigNotFound
}
if s.isOnline() {
_ = s.syncService.SyncPricelistsIfNeeded()
}
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
// Get current component price from local cache
if latestErr == nil && latestPricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
if err == nil && price > 0 {
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
// Fallback to current component price from local cache
component, err := s.localDB.GetLocalComponent(item.LotName)
if err != nil || component.CurrentPrice == nil {
// Keep original item if component not found or no price available
@@ -669,6 +730,9 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
}
localCfg.TotalPrice = &total
if latestErr == nil && latestPricelist != nil {
localCfg.PricelistID = &latestPricelist.ServerID
}
// Set price update timestamp and mark for sync
now := time.Now()
@@ -815,6 +879,9 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
return fmt.Errorf("enqueue create pending change: %w", err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
})
@@ -854,6 +921,9 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
return fmt.Errorf("enqueue %s pending change: %w", operation, err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
})
@@ -958,6 +1028,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
current.Notes = rollbackData.Notes
current.IsTemplate = rollbackData.IsTemplate
current.ServerCount = rollbackData.ServerCount
current.PricelistID = rollbackData.PricelistID
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
current.UpdatedAt = time.Now()
current.SyncStatus = "pending"
@@ -1015,6 +1086,9 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
if err := s.enqueueConfigurationPendingChangeTx(tx, &current, "rollback", version, userID); err != nil {
return fmt.Errorf("enqueue rollback pending change: %w", err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
})
@@ -1038,6 +1112,7 @@ func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx(
IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation),
ConfigurationUUID: localCfg.UUID,
ProjectUUID: localCfg.ProjectUUID,
PricelistID: localCfg.PricelistID,
Operation: operation,
CurrentVersionID: version.ID,
CurrentVersionNo: version.VersionNo,
@@ -1071,6 +1146,21 @@ func (s *LocalConfigurationService) decodeConfigurationSnapshot(data string) (*l
return localdb.DecodeConfigurationSnapshot(data)
}
func (s *LocalConfigurationService) recalculateLocalPricelistUsageTx(tx *gorm.DB) error {
if err := tx.Model(&localdb.LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
return err
}
return tx.Exec(`
UPDATE local_pricelists
SET is_used = 1
WHERE server_id IN (
SELECT DISTINCT pricelist_id
FROM local_configurations
WHERE pricelist_id IS NOT NULL AND is_active = 1
)
`).Error
}
func stringPtrOrNil(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
@@ -1116,3 +1206,25 @@ func (s *LocalConfigurationService) resolveProjectUUID(ownerUsername string, pro
return &project.UUID, nil
}
func (s *LocalConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
if pricelistID != nil && *pricelistID > 0 {
if _, err := s.localDB.GetLocalPricelistByServerID(*pricelistID); err == nil {
return pricelistID, nil
}
if s.isOnline() {
if _, err := s.syncService.SyncPricelists(); err == nil {
if _, err := s.localDB.GetLocalPricelistByServerID(*pricelistID); err == nil {
return pricelistID, nil
}
}
}
return nil, fmt.Errorf("pricelist %d not available locally", *pricelistID)
}
latest, err := s.localDB.GetLatestLocalPricelist()
if err != nil {
return nil, nil
}
return &latest.ServerID, nil
}

View File

@@ -185,6 +185,48 @@ WHERE configuration_uuid = ?`, created.UUID).Scan(&c).Error; err != nil {
}
}
func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
project := &localdb.LocalProject{
UUID: "project-keep",
OwnerUsername: "tester",
Name: "Keep 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)
}
if created.ProjectUUID == nil || *created.ProjectUUID != project.UUID {
t.Fatalf("expected created config project_uuid=%s", project.UUID)
}
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 without 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 newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
t.Helper()

View File

@@ -9,29 +9,79 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"gorm.io/gorm"
)
type Service struct {
repo *repository.PricelistRepository
componentRepo *repository.ComponentRepository
pricingSvc *pricing.Service
db *gorm.DB
}
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service {
type CreateProgress struct {
Current int
Total int
Status string
Message string
Updated int
Errors int
LotName string
}
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
return &Service{
repo: repo,
componentRepo: componentRepo,
pricingSvc: pricingSvc,
db: db,
}
}
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
return s.CreateFromCurrentPricesWithProgress(createdBy, nil)
}
// CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress.
func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgress func(CreateProgress)) (*models.Pricelist, error) {
if s.repo == nil || s.db == nil {
return nil, fmt.Errorf("offline mode: cannot create pricelists")
}
report := func(p CreateProgress) {
if onProgress != nil {
onProgress(p)
}
}
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
updated, errs := 0, 0
if s.pricingSvc != nil {
report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"})
updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) {
if p.Total <= 0 {
return
}
phaseCurrent := 1 + int(float64(p.Current)/float64(p.Total)*90.0)
if phaseCurrent > 91 {
phaseCurrent = 91
}
report(CreateProgress{
Current: phaseCurrent,
Total: 100,
Status: "recalculating",
Message: "Обновление цен компонентов",
Updated: p.Updated,
Errors: p.Errors,
LotName: p.LotName,
})
})
}
report(CreateProgress{Current: 92, Total: 100, Status: "recalculated", Message: "Цены обновлены", Updated: updated, Errors: errs})
report(CreateProgress{Current: 95, Total: 100, Status: "snapshot", Message: "Создание снимка прайслиста"})
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
const maxCreateAttempts = 5
var pricelist *models.Pricelist
@@ -101,6 +151,7 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist,
"items", len(items),
"created_by", createdBy,
)
report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs})
return pricelist, nil
}
@@ -130,6 +181,21 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
return s.repo.List(offset, perPage)
}
// ListActive returns active pricelists with pagination.
func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) {
if s.repo == nil {
return []models.PricelistSummary{}, 0, nil
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
offset := (page - 1) * perPage
return s.repo.ListActive(offset, perPage)
}
// GetByID returns a pricelist by ID
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
if s.repo == nil {
@@ -161,6 +227,22 @@ func (s *Service) Delete(id uint) error {
return s.repo.Delete(id)
}
// SetActive toggles active state for a pricelist.
func (s *Service) SetActive(id uint, isActive bool) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot update pricelists")
}
return s.repo.SetActive(id, isActive)
}
// GetPriceForLot returns price by pricelist/lot.
func (s *Service) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
if s.repo == nil {
return 0, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetPriceForLot(pricelistID, lotName)
}
// CanWrite returns true if the user can create pricelists
func (s *Service) CanWrite() bool {
if s.repo == nil {

View File

@@ -1,17 +1,28 @@
package pricing
import (
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"gorm.io/gorm"
)
type Service struct {
componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository
config config.PricingConfig
db *gorm.DB
}
type RecalculateProgress struct {
Current int
Total int
LotName string
Updated int
Errors int
}
func NewService(
@@ -19,10 +30,16 @@ func NewService(
priceRepo *repository.PriceRepository,
cfg config.PricingConfig,
) *Service {
var db *gorm.DB
if componentRepo != nil {
db = componentRepo.DB()
}
return &Service{
componentRepo: componentRepo,
priceRepo: priceRepo,
config: cfg,
db: db,
}
}
@@ -179,27 +196,183 @@ type PriceStats struct {
// RecalculateAllPrices recalculates prices for all components
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
// Get all components
filter := repository.ComponentFilter{}
offset := 0
limit := 100
return s.RecalculateAllPricesWithProgress(nil)
}
for {
components, _, err := s.componentRepo.List(filter, offset, limit)
if err != nil || len(components) == 0 {
break
}
// RecalculateAllPricesWithProgress recalculates prices and reports progress.
func (s *Service) RecalculateAllPricesWithProgress(onProgress func(RecalculateProgress)) (updated int, errors int) {
if s.db == nil {
return 0, 0
}
for _, comp := range components {
if err := s.UpdateComponentPrice(comp.LotName); err != nil {
errors++
} else {
updated++
// Logic mirrors "Обновить цены" in admin pricing.
var components []models.LotMetadata
if err := s.db.Find(&components).Error; err != nil {
return 0, len(components)
}
total := len(components)
var allLotNames []string
_ = s.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames).Error
type lotDate struct {
Lot string
Date time.Time
}
var latestDates []lotDate
_ = s.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates).Error
lotLatestDate := make(map[string]time.Time, len(latestDates))
for _, ld := range latestDates {
lotLatestDate[ld.Lot] = ld.Date
}
var skipped, manual, unchanged int
now := time.Now()
current := 0
for _, comp := range components {
current++
reportProgress := func() {
if onProgress != nil && (current%10 == 0 || current == total) {
onProgress(RecalculateProgress{
Current: current,
Total: total,
LotName: comp.LotName,
Updated: updated,
Errors: errors,
})
}
}
offset += limit
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
manual++
reportProgress()
continue
}
method := comp.PriceMethod
if method == "" {
method = models.PriceMethodMedian
}
var sourceLots []string
if comp.MetaPrices != "" {
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
} else {
sourceLots = []string{comp.LotName}
}
if len(sourceLots) == 0 {
skipped++
reportProgress()
continue
}
if comp.PriceUpdatedAt != nil {
hasNewData := false
for _, lot := range sourceLots {
if latestDate, ok := lotLatestDate[lot]; ok && latestDate.After(*comp.PriceUpdatedAt) {
hasNewData = true
break
}
}
if !hasNewData {
unchanged++
reportProgress()
continue
}
}
var prices []float64
if comp.PricePeriodDays > 0 {
_ = s.db.Raw(
`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
sourceLots, comp.PricePeriodDays,
).Pluck("price", &prices).Error
} else {
_ = s.db.Raw(
`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
sourceLots,
).Pluck("price", &prices).Error
}
if len(prices) == 0 && comp.PricePeriodDays > 0 {
_ = s.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices).Error
}
if len(prices) == 0 {
skipped++
reportProgress()
continue
}
var basePrice float64
switch method {
case models.PriceMethodAverage:
basePrice = CalculateAverage(prices)
default:
basePrice = CalculateMedian(prices)
}
if basePrice <= 0 {
skipped++
reportProgress()
continue
}
finalPrice := basePrice
if comp.PriceCoefficient != 0 {
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
}
if err := s.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", comp.LotName).
Updates(map[string]interface{}{
"current_price": finalPrice,
"price_updated_at": now,
}).Error; err != nil {
errors++
} else {
updated++
}
reportProgress()
}
if onProgress != nil && total == 0 {
onProgress(RecalculateProgress{
Current: 0,
Total: 0,
LotName: "",
Updated: updated,
Errors: errors,
})
}
return updated, errors
}
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
sources := strings.Split(metaPrices, ",")
var result []string
seen := make(map[string]bool)
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" || source == excludeLot {
continue
}
if strings.HasSuffix(source, "*") {
prefix := strings.TrimSuffix(source, "*")
for _, lot := range allLotNames {
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
@@ -28,11 +29,13 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
}
type CreateProjectRequest struct {
Name string `json:"name"`
Name string `json:"name"`
TrackerURL string `json:"tracker_url"`
}
type UpdateProjectRequest struct {
Name string `json:"name"`
Name string `json:"name"`
TrackerURL *string `json:"tracker_url,omitempty"`
}
type ProjectConfigurationsResult struct {
@@ -52,6 +55,7 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
UUID: uuid.NewString(),
OwnerUsername: ownerUsername,
Name: name,
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
IsActive: true,
IsSystem: false,
CreatedAt: now,
@@ -82,6 +86,11 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
}
localProject.Name = name
if req.TrackerURL != nil {
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
}
localProject.UpdatedAt = time.Now()
localProject.SyncStatus = "pending"
if err := s.localDB.SaveProject(localProject); err != nil {
@@ -260,6 +269,20 @@ func (s *ProjectService) ResolveProjectUUID(ownerUsername string, projectUUID *s
return &resolved, nil
}
func normalizeProjectTrackerURL(projectCode, trackerURL string) string {
trimmedURL := strings.TrimSpace(trackerURL)
if trimmedURL != "" {
return trimmedURL
}
trimmedCode := strings.TrimSpace(projectCode)
if trimmedCode == "" {
return ""
}
return "https://tracker.yandex.ru/" + url.PathEscape(trimmedCode)
}
func (s *ProjectService) enqueueProjectPendingChange(project *localdb.LocalProject, operation string) error {
return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation)
}

View File

@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
@@ -49,6 +51,13 @@ type SyncStatus struct {
NeedsSync bool `json:"needs_sync"`
}
type UserSyncStatus struct {
Username string `json:"username"`
LastSyncAt time.Time `json:"last_sync_at"`
AppVersion string `json:"app_version,omitempty"`
IsOnline bool `json:"is_online"`
}
// ConfigImportResult represents server->local configuration import stats.
type ConfigImportResult struct {
Imported int `json:"imported"`
@@ -56,6 +65,13 @@ type ConfigImportResult struct {
Skipped int `json:"skipped"`
}
// ProjectImportResult represents server->local project import stats.
type ProjectImportResult struct {
Imported int `json:"imported"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
}
// ConfigurationChangePayload is stored in pending_changes.payload for configuration events.
// It carries version metadata so sync can push the latest snapshot and prepare for conflict resolution.
type ConfigurationChangePayload struct {
@@ -63,6 +79,7 @@ type ConfigurationChangePayload struct {
IdempotencyKey string `json:"idempotency_key"`
ConfigurationUUID string `json:"configuration_uuid"`
ProjectUUID *string `json:"project_uuid,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete
CurrentVersionID string `json:"current_version_id,omitempty"`
CurrentVersionNo int `json:"current_version_no,omitempty"`
@@ -144,6 +161,78 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
return result, nil
}
// ImportProjectsToLocal imports projects from MariaDB into local SQLite.
// Existing local projects with pending local changes are skipped to avoid data loss.
func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
mariaDB, err := s.getDB()
if err != nil {
return nil, ErrOffline
}
projectRepo := repository.NewProjectRepository(mariaDB)
result := &ProjectImportResult{}
offset := 0
const limit = 200
for {
serverProjects, _, err := projectRepo.List(offset, limit, true)
if err != nil {
return nil, fmt.Errorf("listing server projects: %w", err)
}
if len(serverProjects) == 0 {
break
}
now := time.Now()
for i := range serverProjects {
project := serverProjects[i]
existing, getErr := s.localDB.GetProjectByUUID(project.UUID)
if getErr != nil && !errors.Is(getErr, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("getting local project %s: %w", project.UUID, getErr)
}
if existing != nil && getErr == nil {
// Keep unsynced local changes intact.
if existing.SyncStatus == "pending" {
result.Skipped++
continue
}
existing.OwnerUsername = project.OwnerUsername
existing.Name = project.Name
existing.TrackerURL = project.TrackerURL
existing.IsActive = project.IsActive
existing.IsSystem = project.IsSystem
existing.CreatedAt = project.CreatedAt
existing.UpdatedAt = project.UpdatedAt
serverID := project.ID
existing.ServerID = &serverID
existing.SyncStatus = "synced"
existing.SyncedAt = &now
if err := s.localDB.SaveProject(existing); err != nil {
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
}
result.Updated++
continue
}
localProject := localdb.ProjectToLocal(&project)
localProject.SyncStatus = "synced"
localProject.SyncedAt = &now
if err := s.localDB.SaveProject(localProject); err != nil {
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
}
result.Imported++
}
offset += len(serverProjects)
}
return result, nil
}
// GetStatus returns the current sync status
func (s *Service) GetStatus() (*SyncStatus, error) {
lastSync := s.localDB.GetLastSyncTime()
@@ -300,11 +389,168 @@ func (s *Service) SyncPricelists() (int, error) {
// Update last sync time
s.localDB.SetLastSyncTime(time.Now())
s.RecordSyncHeartbeat()
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
return synced, nil
}
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
// Only users with write rights are expected to be able to update this table.
func (s *Service) RecordSyncHeartbeat() {
username := strings.TrimSpace(s.localDB.GetDBUser())
if username == "" {
return
}
mariaDB, err := s.getDB()
if err != nil || mariaDB == nil {
return
}
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
slog.Warn("sync heartbeat: failed to ensure table", "error", err)
return
}
now := time.Now().UTC()
if err := mariaDB.Exec(`
INSERT INTO qt_pricelist_sync_status (username, last_sync_at, updated_at, app_version)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_sync_at = VALUES(last_sync_at),
updated_at = VALUES(updated_at),
app_version = VALUES(app_version)
`, username, now, now, appmeta.Version()).Error; err != nil {
slog.Debug("sync heartbeat: skipped", "username", username, "error", err)
}
}
// ListUserSyncStatuses returns users who have recorded sync heartbeat.
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
mariaDB, err := s.getDB()
if err != nil || mariaDB == nil {
return nil, ErrOffline
}
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
return nil, fmt.Errorf("ensure sync status table: %w", err)
}
type row struct {
Username string `gorm:"column:username"`
LastSyncAt time.Time `gorm:"column:last_sync_at"`
AppVersion string `gorm:"column:app_version"`
}
var rows []row
if err := mariaDB.Raw(`
SELECT username, last_sync_at, COALESCE(app_version, '') AS app_version
FROM qt_pricelist_sync_status
ORDER BY last_sync_at DESC, username ASC
`).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("load sync status rows: %w", err)
}
activeUsers, err := s.listConnectedDBUsers(mariaDB)
if err != nil {
slog.Debug("sync status: failed to load connected DB users", "error", err)
activeUsers = map[string]struct{}{}
}
now := time.Now().UTC()
result := make([]UserSyncStatus, 0, len(rows)+len(activeUsers))
for i := range rows {
r := rows[i]
username := strings.TrimSpace(r.Username)
if username == "" {
continue
}
isOnline := now.Sub(r.LastSyncAt) <= onlineThreshold
if _, connected := activeUsers[username]; connected {
isOnline = true
delete(activeUsers, username)
}
appVersion := strings.TrimSpace(r.AppVersion)
result = append(result, UserSyncStatus{
Username: username,
LastSyncAt: r.LastSyncAt,
AppVersion: appVersion,
IsOnline: isOnline,
})
}
for username := range activeUsers {
result = append(result, UserSyncStatus{
Username: username,
LastSyncAt: now,
AppVersion: "",
IsOnline: true,
})
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].IsOnline != result[j].IsOnline {
return result[i].IsOnline
}
if result[i].LastSyncAt.Equal(result[j].LastSyncAt) {
return strings.ToLower(result[i].Username) < strings.ToLower(result[j].Username)
}
return result[i].LastSyncAt.After(result[j].LastSyncAt)
})
return result, nil
}
func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, error) {
type processUserRow struct {
Username string `gorm:"column:username"`
}
var rows []processUserRow
if err := mariaDB.Raw(`
SELECT DISTINCT TRIM(USER) AS username
FROM information_schema.PROCESSLIST
WHERE COALESCE(TRIM(USER), '') <> ''
AND DB = DATABASE()
`).Scan(&rows).Error; err != nil {
return nil, err
}
users := make(map[string]struct{}, len(rows))
for i := range rows {
username := strings.TrimSpace(rows[i].Username)
if username == "" {
continue
}
users[username] = struct{}{}
}
return users, nil
}
func ensureUserSyncStatusTable(db *gorm.DB) error {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
username VARCHAR(100) NOT NULL,
last_sync_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
app_version VARCHAR(64) NULL,
PRIMARY KEY (username),
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
)
`).Error; err != nil {
return err
}
// Backward compatibility for environments where table was created without app_version.
return db.Exec(`
ALTER TABLE qt_pricelist_sync_status
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
`).Error
}
// SyncPricelistItems synchronizes items for a specific pricelist
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
// Get local pricelist
@@ -512,20 +758,8 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
project := payload.Snapshot
project.UUID = payload.ProjectUUID
serverProject, err := projectRepo.GetByUUID(project.UUID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
if createErr := projectRepo.Create(&project); createErr != nil {
return fmt.Errorf("create project on server: %w", createErr)
}
} else {
return fmt.Errorf("get project on server: %w", err)
}
} else {
project.ID = serverProject.ID
if updateErr := projectRepo.Update(&project); updateErr != nil {
return fmt.Errorf("update project on server: %w", updateErr)
}
if err := projectRepo.UpsertByUUID(&project); err != nil {
return fmt.Errorf("upsert project on server: %w", err)
}
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
@@ -610,6 +844,9 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration project: %w", err)
}
if err := s.ensureConfigurationPricelist(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration pricelist: %w", err)
}
// Create on server
if err := configRepo.Create(&cfg); err != nil {
@@ -668,6 +905,9 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration project: %w", err)
}
if err := s.ensureConfigurationPricelist(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration pricelist: %w", err)
}
// Ensure we have a server ID before updating
// If the payload doesn't have ID, get it from local configuration
@@ -678,15 +918,34 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
}
if localCfg.ServerID == nil {
// Configuration hasn't been synced yet, try to find it on server by UUID
serverCfg, err := configRepo.GetByUUID(cfg.UUID)
if err != nil {
return fmt.Errorf("configuration not yet synced to server: %w", err)
// Configuration hasn't been synced yet, try to find it on server by UUID.
// If not found (e.g. stale create was skipped), create it from current snapshot.
serverCfg, getErr := configRepo.GetByUUID(cfg.UUID)
if getErr != nil {
if !errors.Is(getErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("loading configuration from server: %w", getErr)
}
if createErr := configRepo.Create(&cfg); createErr != nil {
// Idempotency fallback: configuration may have been created concurrently.
existing, existingErr := configRepo.GetByUUID(cfg.UUID)
if existingErr != nil {
return fmt.Errorf("creating missing configuration on server: %w", createErr)
}
cfg.ID = existing.ID
}
if cfg.ID == 0 {
existing, existingErr := configRepo.GetByUUID(cfg.UUID)
if existingErr != nil {
return fmt.Errorf("loading created configuration from server: %w", existingErr)
}
cfg.ID = existing.ID
}
} else {
cfg.ID = serverCfg.ID
}
cfg.ID = serverCfg.ID
// Update local with server ID
serverID := serverCfg.ID
serverID := cfg.ID
localCfg.ServerID = &serverID
s.localDB.SaveConfiguration(localCfg)
} else {
@@ -762,7 +1021,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
if modelProject.OwnerUsername == "" {
modelProject.OwnerUsername = cfg.OwnerUsername
}
if createErr := projectRepo.Create(modelProject); createErr != nil {
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
return createErr
}
if modelProject.ID > 0 {
@@ -801,6 +1060,29 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
return nil
}
func (s *Service) ensureConfigurationPricelist(mariaDB *gorm.DB, cfg *models.Configuration) error {
if cfg == nil {
return fmt.Errorf("configuration is nil")
}
pricelistRepo := repository.NewPricelistRepository(mariaDB)
if cfg.PricelistID != nil && *cfg.PricelistID > 0 {
if _, err := pricelistRepo.GetByID(*cfg.PricelistID); err == nil {
return nil
}
}
latest, err := pricelistRepo.GetLatestActive()
if err != nil {
cfg.PricelistID = nil
return nil
}
cfg.PricelistID = &latest.ID
return nil
}
func (s *Service) pushConfigurationRollback(change *localdb.PendingChange) error {
// Last-write-wins for now: rollback is pushed as an update with rollback metadata.
return s.pushConfigurationUpdate(change)
@@ -848,6 +1130,7 @@ func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChan
if currentVersionNo > 0 {
payload.CurrentVersionNo = currentVersionNo
}
payload.PricelistID = currentCfg.PricelistID
}
isStale := false
@@ -885,6 +1168,7 @@ func decodeConfigurationChangePayload(change *localdb.PendingChange) (Configurat
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation),
ConfigurationUUID: cfg.UUID,
ProjectUUID: cfg.ProjectUUID,
PricelistID: cfg.PricelistID,
Operation: change.Operation,
ConflictPolicy: "last_write_wins",
Snapshot: cfg,

View File

@@ -65,6 +65,54 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
}
}
func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
localSync := syncsvc.NewService(nil, local)
projectService := services.NewProjectService(local)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
pushService := syncsvc.NewServiceWithDB(serverDB, local)
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project v1"})
if err != nil {
t.Fatalf("create project: %v", err)
}
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
t.Fatalf("update project: %v", err)
}
cfg, err := configService.Create("tester", &services.CreateConfigRequest{
Name: "Cfg linked",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
ProjectUUID: &project.UUID,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := pushService.PushPendingChanges(); err != nil {
t.Fatalf("push pending changes: %v", err)
}
var serverProject models.Project
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
t.Fatalf("project not pushed to server: %v", err)
}
if serverProject.Name != "Project v2" {
t.Fatalf("expected latest project name, got %q", serverProject.Name)
}
var serverCfg models.Configuration
if err := serverDB.Where("uuid = ?", cfg.UUID).First(&serverCfg).Error; err != nil {
t.Fatalf("configuration not pushed to server: %v", err)
}
if serverCfg.ProjectUUID == nil || *serverCfg.ProjectUUID != project.UUID {
t.Fatalf("expected project_uuid=%s on pushed config, got %v", project.UUID, serverCfg.ProjectUUID)
}
}
func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
@@ -202,6 +250,57 @@ func TestPushPendingChangesCreateIsIdempotent(t *testing.T) {
}
}
func TestPushPendingChangesCreateThenUpdateBeforeFirstPush(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
localSync := syncsvc.NewService(nil, local)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
pushService := syncsvc.NewServiceWithDB(serverDB, local)
created, err := configService.Create("tester", &services.CreateConfigRequest{
Name: "Cfg v1",
Items: models.ConfigItems{{LotName: "CPU_X", Quantity: 1, UnitPrice: 700}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := configService.UpdateNoAuth(created.UUID, &services.CreateConfigRequest{
Name: "Cfg v2",
Items: models.ConfigItems{{LotName: "CPU_X", Quantity: 3, UnitPrice: 700}},
ServerCount: 1,
ProjectUUID: created.ProjectUUID,
}); err != nil {
t.Fatalf("update config before first push: %v", err)
}
pushed, err := pushService.PushPendingChanges()
if err != nil {
t.Fatalf("push pending changes: %v", err)
}
if pushed < 1 {
t.Fatalf("expected at least one pushed change, got %d", pushed)
}
var serverCfg models.Configuration
if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil {
t.Fatalf("configuration not pushed to server: %v", err)
}
if serverCfg.Name != "Cfg v2" {
t.Fatalf("expected latest update to be pushed, got %q", serverCfg.Name)
}
localCfg, err := local.GetConfigurationByUUID(created.UUID)
if err != nil {
t.Fatalf("get local config: %v", err)
}
if localCfg.ServerID == nil || *localCfg.ServerID == 0 {
t.Fatalf("expected local configuration to have server_id after push, got %+v", localCfg.ServerID)
}
}
func newLocalDBForSyncTest(t *testing.T) *localdb.LocalDB {
t.Helper()
localPath := filepath.Join(t.TempDir(), "local.db")
@@ -226,6 +325,7 @@ CREATE TABLE qt_projects (
uuid TEXT NOT NULL UNIQUE,
owner_username TEXT NOT NULL,
name TEXT NOT NULL,
tracker_url TEXT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
is_system INTEGER NOT NULL DEFAULT 0,
created_at DATETIME,
@@ -248,6 +348,7 @@ CREATE TABLE qt_configurations (
notes TEXT NULL,
is_template INTEGER NOT NULL DEFAULT 0,
server_count INTEGER NOT NULL DEFAULT 1,
pricelist_id INTEGER NULL,
price_updated_at DATETIME NULL,
created_at DATETIME
);`).Error; err != nil {

View File

@@ -83,7 +83,11 @@ func (w *Worker) runSync() {
err = w.service.SyncPricelistsIfNeeded()
if err != nil {
w.logger.Warn("background sync: failed to sync pricelists", "error", err)
return
}
// Mark user's sync heartbeat (used for online/offline status in UI).
w.service.RecordSyncHeartbeat()
w.logger.Info("background sync cycle completed")
}

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
username VARCHAR(100) NOT NULL,
last_sync_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
app_version VARCHAR(64) NULL,
PRIMARY KEY (username),
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
);

View File

@@ -0,0 +1,37 @@
-- Add pricelist binding to configurations
ALTER TABLE qt_configurations
ADD COLUMN pricelist_id BIGINT UNSIGNED NULL AFTER server_count;
ALTER TABLE qt_configurations
ADD INDEX idx_qt_configurations_pricelist_id (pricelist_id),
ADD CONSTRAINT fk_qt_configurations_pricelist_id
FOREIGN KEY (pricelist_id)
REFERENCES qt_pricelists(id)
ON DELETE RESTRICT
ON UPDATE CASCADE;
-- Backfill existing configurations to latest active pricelist
SET @latest_active_pricelist_id := (
SELECT id
FROM qt_pricelists
WHERE is_active = 1
ORDER BY created_at DESC
LIMIT 1
);
UPDATE qt_configurations
SET pricelist_id = @latest_active_pricelist_id
WHERE pricelist_id IS NULL
AND @latest_active_pricelist_id IS NOT NULL;
-- Recalculate usage_count from configuration bindings
UPDATE qt_pricelists SET usage_count = 0;
UPDATE qt_pricelists pl
JOIN (
SELECT pricelist_id, COUNT(*) AS cnt
FROM qt_configurations
WHERE pricelist_id IS NOT NULL
GROUP BY pricelist_id
) cfg ON cfg.pricelist_id = pl.id
SET pl.usage_count = cfg.cnt;

View File

@@ -0,0 +1,2 @@
ALTER TABLE qt_pricelist_sync_status
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;

View File

@@ -0,0 +1,7 @@
ALTER TABLE qt_projects
ADD COLUMN tracker_url VARCHAR(500) NULL AFTER name;
UPDATE qt_projects
SET tracker_url = CONCAT('https://tracker.yandex.ru/', TRIM(name))
WHERE (tracker_url IS NULL OR tracker_url = '')
AND TRIM(COALESCE(name, '')) <> '';

View File

@@ -0,0 +1,51 @@
# QuoteForge v1.0.3
Дата релиза: 2026-02-06
Тег: `v1.0.3`
Диапазон изменений: `v1.0.2..v1.0.3`
## Что нового
- Добавлена страница управления проектами `/projects` с:
- датой и временем создания проекта;
- сортировкой по названию и дате создания;
- серверной пагинацией;
- фильтром по автору в заголовке таблицы.
- Добавлена отдельная вкладка `Статус синхронизации` на уровне `Алерты / Компоненты / Прайслисты`.
- Во вкладке статуса синхронизации отображаются:
- пользователь;
- версия приложения;
- статус (`онлайн` или относительное время последней синхронизации).
## Изменения синхронизации
- Реализован heartbeat синхронизации пользователей в MariaDB: `qt_pricelist_sync_status`.
- Добавлен API `GET /api/sync/users-status` для UI статуса синхронизации.
- Логика онлайн-статуса рассчитана от интервала фоновой синхронизации: `5 минут + 10%`.
- В heartbeat фиксируется версия приложения (`app_version`).
## Важные исправления
- Исправлено восстановление отсутствующей серверной конфигурации при push обновлений.
- Исправлено экранирование паролей в MySQL DSN в setup.
- Улучшена логика запуска SQL-миграций на старте при отсутствии прав/необходимости.
- Обновлена логика пересчета прайслистов через админский price-refresh.
## Миграции и совместимость
Добавлены SQL-миграции:
- `migrations/010_add_pricelist_sync_status.sql`
- `migrations/011_add_app_version_to_pricelist_sync_status.sql`
Релиз совместим с предыдущей веткой `v1.0.x`; новая таблица синхронизации создается автоматически.
## Коммиты в релизе
- `b1b50ce` Add projects table controls and sync status tab with app version
- `6ab1e98` sync: recover missing server config during update push
- `a1d2192` Fix MySQL DSN escaping for setup passwords and clarify DB user setup
- `a90c07c` update stale files list
- `e9307c4` Apply remaining pricelist and local-first updates
- `1b48401` Use admin price-refresh logic for pricelist recalculation
- `4a86f7b` fix: skip startup sql migrations when not needed or no permissions

View File

@@ -10,17 +10,18 @@
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
<button onclick="loadTab('sync-status')" id="btn-sync-status" class="text-gray-600 hidden">Статус синхронизации</button>
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
</div>
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Пересчитать цены
Обновить цены
</button>
</div>
<!-- Progress bar -->
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
<div class="flex justify-between text-sm text-gray-700 mb-2">
<span id="progress-text" class="font-medium">Пересчёт цен...</span>
<span id="progress-text" class="font-medium">Обновление цен...</span>
<span id="progress-percent" class="font-bold">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4">
@@ -85,6 +86,30 @@
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
</div>
<!-- Sync Status Tab Content (hidden by default) -->
<div id="sync-status-tab-content" class="hidden">
<div class="mb-4">
<h2 class="text-xl font-semibold">Статус синхронизации</h2>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Пользователь</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия приложения</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Статус</th>
</tr>
</thead>
<tbody id="sync-users-status-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create Modal -->
<div id="pricelists-create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
@@ -94,6 +119,16 @@
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
</p>
<form id="pricelists-create-form" class="space-y-4">
<div id="pricelist-create-progress" class="hidden p-3 bg-blue-50 rounded-lg border border-blue-200">
<div class="flex justify-between items-center text-sm mb-2">
<span id="pricelist-create-progress-text" class="font-medium">Подготовка...</span>
<span id="pricelist-create-progress-percent" class="font-bold">0%</span>
</div>
<div class="w-full bg-blue-100 rounded-full h-3 overflow-hidden">
<div id="pricelist-create-progress-bar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div id="pricelist-create-progress-stats" class="text-xs text-gray-600 mt-2"></div>
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="closePricelistsCreateModal()"
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
@@ -216,16 +251,21 @@ let pricelistsPage = 1;
let pricelistsCanWrite = false;
let isCreatingPricelist = false;
let cachedDbUsername = null;
let syncUsersStatusTimer = null;
async function loadTab(tab) {
currentTab = tab;
currentPage = 1;
currentSearch = '';
document.getElementById('search-input').value = '';
if (tab !== 'sync-status') {
stopSyncUsersStatusRefresh();
}
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-sync-status').className = (tab === 'sync-status' ? 'text-blue-600 font-medium' : 'text-gray-600') + (pricelistsCanWrite ? '' : ' hidden');
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
// Show/hide elements based on tab
@@ -234,35 +274,69 @@ async function loadTab(tab) {
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
} else if (tab === 'all-configs') {
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
} else if (tab === 'pricelists') {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = '';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = 'hidden';
// Load pricelists when pricelists tab is selected
checkPricelistWritePermission();
loadPricelists(1);
} else if (tab === 'sync-status') {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = '';
document.getElementById('tab-content').className = 'hidden';
await checkPricelistWritePermission();
if (!pricelistsCanWrite) {
await loadTab('alerts');
return;
}
await loadUsersSyncStatus();
startSyncUsersStatusRefresh();
} else {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
}
if (tab !== 'pricelists') {
if (tab !== 'pricelists' && tab !== 'sync-status') {
await loadData();
}
}
function stopSyncUsersStatusRefresh() {
if (syncUsersStatusTimer) {
clearInterval(syncUsersStatusTimer);
syncUsersStatusTimer = null;
}
}
function startSyncUsersStatusRefresh() {
stopSyncUsersStatusRefresh();
syncUsersStatusTimer = setInterval(() => {
if (currentTab === 'sync-status' && pricelistsCanWrite) {
loadUsersSyncStatus();
}
}, 30000);
}
async function loadData() {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
@@ -750,11 +824,11 @@ function recalculateAll() {
// Show progress bar IMMEDIATELY
btn.disabled = true;
btn.textContent = 'Пересчёт...';
btn.textContent = 'Обновление...';
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
progressText.textContent = 'Запуск пересчёта...';
progressText.textContent = 'Запуск обновления...';
progressPercent.textContent = '0%';
progressStats.textContent = 'Подготовка...';
@@ -769,7 +843,7 @@ function recalculateAll() {
reader.read().then(({done, value}) => {
if (done) {
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
btn.textContent = 'Обновить цены';
progressText.textContent = 'Готово!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
setTimeout(() => {
@@ -794,7 +868,7 @@ function recalculateAll() {
progressPercent.textContent = percent + '%';
if (data.status === 'completed') {
progressText.textContent = 'Пересчёт завершён!';
progressText.textContent = 'Обновление завершено!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
} else {
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
@@ -816,7 +890,7 @@ function recalculateAll() {
console.error('Fetch error:', e);
alert('Ошибка соединения');
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
btn.textContent = 'Обновить цены';
progressContainer.style.display = 'none';
});
}
@@ -892,11 +966,12 @@ function renderAllConfigs(configs) {
document.getElementById('tab-content').innerHTML = html;
}
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
await checkPricelistWritePermission();
// Check URL params for initial tab
const urlParams = new URLSearchParams(window.location.search);
const initialTab = urlParams.get('tab') || 'alerts';
loadTab(initialTab);
await loadTab(initialTab);
// Add event listeners for preview updates
document.getElementById('modal-period').addEventListener('change', fetchPreview);
@@ -920,9 +995,89 @@ async function checkPricelistWritePermission() {
Создать прайслист
</button>
`;
document.getElementById('btn-sync-status').classList.remove('hidden');
if (currentTab === 'sync-status') {
await loadUsersSyncStatus();
startSyncUsersStatusRefresh();
}
} else {
document.getElementById('pricelists-create-btn-container').innerHTML = '';
document.getElementById('btn-sync-status').classList.add('hidden');
stopSyncUsersStatusRefresh();
if (currentTab === 'sync-status') {
await loadTab('alerts');
}
}
} catch (e) {
console.error('Failed to check pricelist write permission:', e);
document.getElementById('btn-sync-status').classList.add('hidden');
stopSyncUsersStatusRefresh();
}
}
function formatRelativeTime(lastSyncAt) {
const timestamp = new Date(lastSyncAt);
if (Number.isNaN(timestamp.getTime())) return '—';
const diffMinutes = Math.max(1, Math.floor((Date.now() - timestamp.getTime()) / 60000));
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} ч назад`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays} дн назад`;
const diffWeeks = Math.floor(diffDays / 7);
if (diffWeeks < 5) return `${diffWeeks} нед назад`;
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12) return `${diffMonths} мес назад`;
const diffYears = Math.floor(diffDays / 365);
return `${diffYears} г назад`;
}
async function loadUsersSyncStatus() {
if (!pricelistsCanWrite) return;
const body = document.getElementById('sync-users-status-body');
if (!body) return;
try {
const resp = await fetch('/api/sync/users-status');
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || 'Ошибка загрузки');
}
const users = data.users || [];
if (users.length === 0) {
body.innerHTML = `
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">
Нет данных о синхронизации пользователей
</td>
</tr>
`;
return;
}
body.innerHTML = users.map(u => {
const statusClass = u.is_online ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700';
const statusText = u.is_online ? 'онлайн' : formatRelativeTime(u.last_sync_at);
return `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">${escapeHtml(u.username || '—')}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${escapeHtml(u.app_version || '—')}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
</td>
</tr>
`;
}).join('');
} catch (e) {
body.innerHTML = `
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-red-600">
Ошибка загрузки статусов синхронизации: ${escapeHtml(e.message || String(e))}
</td>
</tr>
`;
}
}
@@ -965,6 +1120,10 @@ function renderPricelists(pricelists) {
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
if (pricelistsCanWrite) {
const toggleLabel = pl.is_active ? 'Деактивировать' : 'Активировать';
actions += ` <button onclick="togglePricelistActive(${pl.id}, ${pl.is_active ? 'false' : 'true'})" class="text-indigo-600 hover:text-indigo-800 text-sm ml-2">${toggleLabel}</button>`;
}
if (pricelistsCanWrite && pl.usage_count === 0) {
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
}
@@ -989,6 +1148,33 @@ function renderPricelists(pricelists) {
document.getElementById('pricelists-body').innerHTML = html;
}
async function togglePricelistActive(id, isActive) {
// Check if online before toggling
const isOnline = await checkOnlineStatus();
if (!isOnline) {
showToast('Изменение статуса прайслиста доступно только в онлайн режиме', 'error');
return;
}
try {
const resp = await fetch(`/api/pricelists/${id}/active`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: isActive })
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to update status');
}
showToast('Статус прайслиста обновлен', 'success');
loadPricelists(pricelistsPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
function renderPricelistsPagination(total, page, perPage) {
const totalPages = Math.ceil(total / perPage);
if (totalPages <= 1) {
@@ -1024,6 +1210,7 @@ async function loadPricelistsDbUsername() {
function openPricelistsCreateModal() {
document.getElementById('pricelists-create-modal').classList.remove('hidden');
document.getElementById('pricelists-create-modal').classList.add('flex');
resetPricelistCreateProgress();
loadPricelistsDbUsername();
}
@@ -1049,20 +1236,88 @@ async function createPricelist() {
throw new Error('Создание прайслистов доступно только в онлайн режиме');
}
const resp = await fetch('/api/pricelists', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
const progressBox = document.getElementById('pricelist-create-progress');
const progressBar = document.getElementById('pricelist-create-progress-bar');
const progressText = document.getElementById('pricelist-create-progress-text');
const progressPercent = document.getElementById('pricelist-create-progress-percent');
const progressStats = document.getElementById('pricelist-create-progress-stats');
progressBox.classList.remove('hidden');
progressBar.style.width = '0%';
progressText.textContent = 'Запуск создания прайслиста...';
progressPercent.textContent = '0%';
progressStats.textContent = '';
const resp = await fetch('/api/pricelists/create-with-progress', {
method: 'POST'
});
if (!resp.ok) {
const data = await resp.json();
let data = {};
try { data = await resp.json(); } catch (_) {}
throw new Error(data.error || 'Failed to create pricelist');
}
return await resp.json();
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let completedPricelist = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n');
for (const line of lines) {
if (!line.startsWith('data:')) continue;
let data;
try {
data = JSON.parse(line.slice(5).trim());
} catch (_) {
continue;
}
const current = Number(data.current || 0);
const total = Number(data.total || 0);
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
progressBar.style.width = percent + '%';
progressPercent.textContent = percent + '%';
if (data.lot_name) {
progressText.textContent = (data.message || 'Обработка') + ': ' + data.lot_name;
} else {
progressText.textContent = data.message || 'Обработка...';
}
if (data.updated !== undefined || data.errors !== undefined) {
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ошибок: ' + (data.errors || 0);
}
if (data.status === 'error') {
throw new Error(data.message || 'Ошибка создания прайслиста');
}
if (data.status === 'completed' && data.pricelist) {
completedPricelist = data.pricelist;
}
}
}
if (!completedPricelist) {
throw new Error('Создание прервано: не получен результат');
}
return completedPricelist;
}
function resetPricelistCreateProgress() {
const progressBox = document.getElementById('pricelist-create-progress');
const progressBar = document.getElementById('pricelist-create-progress-bar');
const progressText = document.getElementById('pricelist-create-progress-text');
const progressPercent = document.getElementById('pricelist-create-progress-percent');
const progressStats = document.getElementById('pricelist-create-progress-stats');
progressBox.classList.add('hidden');
progressBar.style.width = '0%';
progressText.textContent = 'Подготовка...';
progressPercent.textContent = '0%';
progressStats.textContent = '';
}
async function deletePricelist(id) {

View File

@@ -63,10 +63,16 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
<select id="create-project-select"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">Без проекта</option>
</select>
<input id="create-project-input"
list="create-project-options"
placeholder="Начните вводить название проекта"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="create-project-options"></datalist>
<div class="mt-2 flex justify-between items-center gap-3">
<button type="button" onclick="clearCreateProjectInput()" class="text-sm text-gray-600 hover:text-gray-800">
Без проекта
</button>
</div>
</div>
</div>
@@ -171,10 +177,10 @@
<div id="create-project-on-move-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-3">Проект не найден</h2>
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. Создать и привязать квоту?</p>
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
<div class="flex justify-end space-x-3">
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
</div>
</div>
</div>
@@ -190,6 +196,8 @@ let projectsCache = [];
let projectNameByUUID = {};
let pendingMoveConfigUUID = '';
let pendingMoveProjectName = '';
let pendingCreateConfigName = '';
let pendingCreateProjectName = '';
function renderConfigs(configs) {
const emptyText = configStatusMode === 'archived'
@@ -407,6 +415,7 @@ async function cloneConfig() {
function openCreateModal() {
document.getElementById('opportunity-number').value = '';
document.getElementById('create-project-input').value = '';
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
document.getElementById('opportunity-number').focus();
@@ -425,8 +434,25 @@ async function createConfig() {
return;
}
const projectUUID = document.getElementById('create-project-select').value;
const projectName = document.getElementById('create-project-input').value.trim();
let projectUUID = '';
if (projectName) {
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
if (existingProject) {
projectUUID = existingProject.uuid;
} else {
pendingCreateConfigName = name;
pendingCreateProjectName = projectName;
openCreateProjectOnCreateModal(projectName);
return;
}
}
await createConfigWithProject(name, projectUUID);
}
async function createConfigWithProject(name, projectUUID) {
try {
const resp = await fetch('/api/configs', {
method: 'POST',
@@ -442,16 +468,17 @@ async function createConfig() {
})
});
const config = await resp.json();
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось создать'));
return;
alert('Ошибка: ' + (config.error || 'Не удалось создать'));
return false;
}
const config = await resp.json();
window.location.href = '/configurator?uuid=' + config.uuid;
return true;
} catch(e) {
alert('Ошибка создания конфигурации');
return false;
}
}
@@ -510,8 +537,22 @@ function clearMoveProjectInput() {
document.getElementById('move-project-input').value = '';
}
function clearCreateProjectInput() {
document.getElementById('create-project-input').value = '';
}
function openCreateProjectOnMoveModal(projectName) {
document.getElementById('create-project-on-move-name').textContent = projectName;
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
document.getElementById('create-project-on-move-modal').classList.add('flex');
}
function openCreateProjectOnCreateModal(projectName) {
document.getElementById('create-project-on-move-name').textContent = projectName;
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
document.getElementById('create-project-on-move-modal').classList.add('flex');
}
@@ -521,9 +562,43 @@ function closeCreateProjectOnMoveModal() {
document.getElementById('create-project-on-move-modal').classList.remove('flex');
pendingMoveConfigUUID = '';
pendingMoveProjectName = '';
pendingCreateConfigName = '';
pendingCreateProjectName = '';
}
async function confirmCreateProjectOnMove() {
if (pendingCreateConfigName && pendingCreateProjectName) {
const configName = pendingCreateConfigName;
const projectName = pendingCreateProjectName;
try {
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name: projectName })
});
if (!createResp.ok) {
const err = await createResp.json();
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
return;
}
const newProject = await createResp.json();
pendingCreateConfigName = '';
pendingCreateProjectName = '';
await loadProjectsForConfigUI();
const created = await createConfigWithProject(configName, newProject.uuid);
if (created) {
closeCreateProjectOnMoveModal();
} else {
closeCreateProjectOnMoveModal();
document.getElementById('create-project-input').value = projectName;
}
} catch (e) {
alert('Ошибка создания проекта');
}
return;
}
const configUUID = pendingMoveConfigUUID;
const projectName = pendingMoveProjectName;
if (!configUUID || !projectName) {
@@ -544,10 +619,15 @@ async function confirmCreateProjectOnMove() {
}
const newProject = await createResp.json();
pendingMoveConfigUUID = '';
pendingMoveProjectName = '';
await loadProjectsForConfigUI();
document.getElementById('move-project-input').value = projectName;
const moved = await moveConfigToProject(configUUID, newProject.uuid);
if (moved) {
closeCreateProjectOnMoveModal();
closeMoveProjectModal();
} else {
closeCreateProjectOnMoveModal();
}
} catch (e) {
alert('Ошибка создания проекта');
@@ -760,16 +840,18 @@ async function loadProjectsForConfigUI() {
const data = await resp.json();
projectsCache = (data.projects || []);
const select = document.getElementById('create-project-select');
if (select) {
select.innerHTML = '<option value="">Без проекта</option>';
projectsCache.forEach(project => {
projectNameByUUID[project.uuid] = project.name;
});
const createOptions = document.getElementById('create-project-options');
if (createOptions) {
createOptions.innerHTML = '';
projectsCache.forEach(project => {
projectNameByUUID[project.uuid] = project.name;
if (!project.is_active) return;
const option = document.createElement('option');
option.value = project.uuid;
option.textContent = project.name;
select.appendChild(option);
option.value = project.name;
createOptions.appendChild(option);
});
}
} catch (e) {

View File

@@ -16,7 +16,7 @@
</div>
<div id="save-buttons" class="hidden flex items-center space-x-2">
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Пересчитать цену
Обновить цены
</button>
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Сохранить
@@ -34,6 +34,14 @@
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updateServerCount()">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Прайслист</label>
<select id="pricelist-select"
class="w-56 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updatePricelistSelection()">
<option value="">Загрузка...</option>
</select>
</div>
<div class="text-sm text-gray-500">
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
</div>
@@ -224,6 +232,7 @@ let cart = [];
let categoryOrderMap = {}; // Category code -> display_order mapping
let autoSaveTimeout = null; // Timeout for debounced autosave
let serverCount = 1; // Server count for the configuration
let selectedPricelistId = null; // Selected pricelist (server ID)
// Autocomplete state
let autocompleteInput = null;
@@ -296,6 +305,7 @@ document.addEventListener('DOMContentLoaded', async function() {
serverCount = config.server_count || 1;
document.getElementById('server-count').value = serverCount;
document.getElementById('total-server-count').textContent = serverCount;
selectedPricelistId = config.pricelist_id || null;
if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({
@@ -322,6 +332,7 @@ document.addEventListener('DOMContentLoaded', async function() {
return;
}
await loadActivePricelists();
await loadAllComponents();
renderTab();
updateCartUI();
@@ -361,6 +372,44 @@ function updateServerCount() {
triggerAutoSave();
}
async function loadActivePricelists() {
const select = document.getElementById('pricelist-select');
if (!select) return;
try {
const resp = await fetch('/api/pricelists?active_only=true&per_page=200');
const data = await resp.json();
const pricelists = data.pricelists || [];
if (pricelists.length === 0) {
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
selectedPricelistId = null;
return;
}
select.innerHTML = pricelists.map(pl => {
return `<option value="${pl.id}">${pl.version}</option>`;
}).join('');
const existing = selectedPricelistId && pricelists.some(pl => Number(pl.id) === Number(selectedPricelistId));
if (existing) {
select.value = String(selectedPricelistId);
} else {
selectedPricelistId = Number(pricelists[0].id);
select.value = String(selectedPricelistId);
}
} catch (e) {
select.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
function updatePricelistSelection() {
const select = document.getElementById('pricelist-select');
const next = parseInt(select.value);
selectedPricelistId = Number.isFinite(next) && next > 0 ? next : null;
triggerAutoSave();
}
function getCategoryFromLotName(lotName) {
const parts = lotName.split('_');
return parts[0] || '';
@@ -1133,7 +1182,8 @@ async function saveConfig(showNotification = true) {
items: cart,
custom_price: customPrice,
notes: '',
server_count: serverCountValue
server_count: serverCountValue,
pricelist_id: selectedPricelistId
})
});
@@ -1327,6 +1377,17 @@ async function refreshPrices() {
if (config.price_updated_at) {
updatePriceUpdateDate(config.price_updated_at);
}
if (config.pricelist_id) {
selectedPricelistId = config.pricelist_id;
const select = document.getElementById('pricelist-select');
if (select) {
const hasOption = Array.from(select.options).some(opt => Number(opt.value) === Number(selectedPricelistId));
if (!hasOption) {
await loadActivePricelists();
}
select.value = String(selectedPricelistId);
}
}
// Re-render UI
renderTab();

View File

@@ -147,12 +147,15 @@
let settings = [];
const hasManualPrice = item.manual_price && item.manual_price > 0;
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
const method = (item.price_method || '').toLowerCase();
// Method indicator
if (hasManualPrice) {
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
} else if (item.price_method === 'average') {
} else if (method === 'average') {
settings.push('Сред');
} else if (method === 'weighted_median') {
settings.push('Взвеш. мед');
} else {
settings.push('Мед');
}

View File

@@ -21,6 +21,11 @@
Импорт квоты
</button>
</div>
<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>
</div>
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
<button id="status-active-btn" onclick="setConfigStatusMode('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">
@@ -120,6 +125,12 @@ function escapeHtml(text) {
return div.innerHTML;
}
function resolveProjectTrackerURL(projectData) {
if (!projectData) return '';
const explicitURL = (projectData.tracker_url || '').trim();
return explicitURL;
}
function setConfigStatusMode(mode) {
if (mode !== 'active' && mode !== 'archived') return;
configStatusMode = mode;
@@ -218,6 +229,20 @@ async function loadProject() {
}
project = await resp.json();
document.getElementById('project-title').textContent = project.name;
const trackerLink = document.getElementById('tracker-link');
if (trackerLink) {
if (project && project.is_system) {
trackerLink.classList.add('hidden');
return true;
}
const trackerURL = resolveProjectTrackerURL(project);
if (trackerURL) {
trackerLink.href = trackerURL;
trackerLink.classList.remove('hidden');
} else {
trackerLink.classList.add('hidden');
}
}
return true;
}

View File

@@ -8,7 +8,7 @@
<a href="/configs" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
Все конфигурации
</a>
<button onclick="createProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
<button onclick="openCreateProjectModal()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
+ Новый проект
</button>
</div>
@@ -27,9 +27,40 @@
<div id="projects-table" class="bg-white rounded-lg shadow p-4 text-gray-500">Загрузка...</div>
</div>
<div id="create-project-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>
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button type="button" onclick="closeCreateProjectModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button type="button" onclick="createProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать</button>
</div>
</div>
</div>
<script>
let status = 'active';
let projectsSearch = '';
let authorSearch = '';
let currentPage = 1;
let perPage = 10;
let sortField = 'created_at';
let sortDir = 'desc';
let createProjectTrackerManuallyEdited = false;
let createProjectLastAutoTrackerURL = '';
const trackerBaseURL = 'https://tracker.yandex.ru/';
function escapeHtml(text) {
const div = document.createElement('div');
@@ -41,8 +72,33 @@ function formatMoney(v) {
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
}
function formatDateTime(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function toggleSort(field) {
if (sortField === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDir = field === 'name' ? 'asc' : 'desc';
}
currentPage = 1;
loadProjects();
}
function setStatus(value) {
status = value;
currentPage = 1;
document.getElementById('status-active-btn').className = value === 'active'
? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white'
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
@@ -57,36 +113,73 @@ async function loadProjects() {
root.innerHTML = '<div class="text-gray-500">Загрузка...</div>';
let rows = [];
let total = 0;
let totalPages = 0;
let page = currentPage;
try {
const resp = await fetch('/api/projects?status=' + status + '&search=' + encodeURIComponent(projectsSearch));
const params = new URLSearchParams({
status: status,
search: projectsSearch,
author: authorSearch,
page: String(currentPage),
per_page: String(perPage),
sort: sortField,
dir: sortDir
});
const resp = await fetch('/api/projects?' + params.toString());
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
const data = await resp.json();
rows = data.projects || [];
total = data.total || 0;
totalPages = data.total_pages || 0;
page = data.page || currentPage;
currentPage = page;
} catch (e) {
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
return;
}
if (!rows.length) {
root.innerHTML = '<div class="text-gray-500">Проектов нет</div>';
return;
}
let html = '<div class="overflow-x-auto"><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 += '<thead class="bg-gray-50">';
html += '<tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта';
if (sortField === 'name') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></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">';
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан';
if (sortField === 'created_at') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></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-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
html += '</tr>';
html += '<tr>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '</tr>';
html += '</thead><tbody class="divide-y">';
if (!rows.length) {
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
}
rows.forEach(p => {
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
@@ -117,21 +210,94 @@ async function loadProjects() {
});
html += '</tbody></table></div>';
if (totalPages > 1) {
html += '<div class="flex items-center justify-between mt-4 pt-4 border-t">';
html += '<div class="text-sm text-gray-600">Показано ' + rows.length + ' из ' + total + '</div>';
html += '<div class="inline-flex items-center gap-1">';
html += '<button type="button" onclick="goToPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page <= 1 ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">&larr;</button>';
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, page + 2);
for (let i = startPage; i <= endPage; i++) {
html += '<button type="button" onclick="goToPage(' + i + ')" class="px-3 py-1 text-sm border rounded ' + (i === page ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-700 border-gray-300 hover:bg-gray-50') + '">' + i + '</button>';
}
html += '<button type="button" onclick="goToPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page >= totalPages ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">&rarr;</button>';
html += '</div>';
html += '</div>';
}
root.innerHTML = html;
const authorInput = document.getElementById('projects-author-filter');
if (authorInput) {
authorInput.addEventListener('input', function(e) {
authorSearch = (e.target.value || '').trim();
currentPage = 1;
loadProjects();
});
}
}
function goToPage(page) {
if (page < 1) return;
currentPage = page;
loadProjects();
}
function buildTrackerURLFromProjectCode(projectCode) {
const code = (projectCode || '').trim();
if (!code) return '';
return trackerBaseURL + encodeURIComponent(code);
}
function openCreateProjectModal() {
const codeInput = document.getElementById('create-project-code');
const trackerInput = document.getElementById('create-project-tracker-url');
codeInput.value = '';
trackerInput.value = '';
createProjectTrackerManuallyEdited = false;
createProjectLastAutoTrackerURL = '';
document.getElementById('create-project-modal').classList.remove('hidden');
document.getElementById('create-project-modal').classList.add('flex');
codeInput.focus();
}
function closeCreateProjectModal() {
document.getElementById('create-project-modal').classList.add('hidden');
document.getElementById('create-project-modal').classList.remove('flex');
}
function updateCreateProjectTrackerURL() {
const codeInput = document.getElementById('create-project-code');
const trackerInput = document.getElementById('create-project-tracker-url');
const generatedURL = buildTrackerURLFromProjectCode(codeInput.value);
if (!createProjectTrackerManuallyEdited || trackerInput.value.trim() === '' || trackerInput.value === createProjectLastAutoTrackerURL) {
trackerInput.value = generatedURL;
createProjectLastAutoTrackerURL = generatedURL;
}
}
async function createProject() {
const name = prompt('Название проекта');
if (!name || !name.trim()) return;
const codeInput = document.getElementById('create-project-code');
const trackerInput = document.getElementById('create-project-tracker-url');
const name = (codeInput.value || '').trim();
if (!name) {
alert('Введите код проекта');
return;
}
const resp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name.trim()})
body: JSON.stringify({
name: name,
tracker_url: (trackerInput.value || '').trim()
})
});
if (!resp.ok) {
alert('Не удалось создать проект');
return;
}
closeCreateProjectModal();
loadProjects();
}
@@ -223,8 +389,37 @@ loadProjects();
document.getElementById('projects-search').addEventListener('input', function(e) {
projectsSearch = (e.target.value || '').trim();
currentPage = 1;
loadProjects();
});
document.getElementById('create-project-code').addEventListener('input', function() {
updateCreateProjectTrackerURL();
});
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
});
document.getElementById('create-project-code').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
createProject();
}
});
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
createProject();
}
});
document.getElementById('create-project-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeCreateProjectModal();
}
});
</script>
{{end}}