Compare commits
7 Commits
06d0e8b14b
...
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
726dccb07c | ||
|
|
38d7332a38 | ||
|
|
c0beed021c | ||
|
|
08b95c293c | ||
|
|
c418d6cfc3 | ||
|
|
548a256d04 | ||
|
|
77c00de97a |
19
.gitignore
vendored
19
.gitignore
vendored
@@ -16,25 +16,6 @@ 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
|
||||
|
||||
34
README.md
34
README.md
@@ -113,52 +113,30 @@ go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
|
||||
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
|
||||
|
||||
```sql
|
||||
-- 1) Создать пользователя (если его ещё нет)
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||
-- 1) Создать (или оставить существующего) пользователя
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
|
||||
-- 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) Сбросить лишние права
|
||||
-- 2) Сбросить лишние права (без пересоздания пользователя)
|
||||
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
|
||||
|
||||
-- 5) Чтение данных для конфигуратора и синка
|
||||
-- 3) Чтение данных для конфигуратора и синка
|
||||
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'@'%';
|
||||
|
||||
-- 6) Работа с конфигурациями
|
||||
-- 4) Работа с конфигурациями
|
||||
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`, если пользователь не должен управлять прайслистами;
|
||||
- если видите ошибку `Access denied for user ...@'<ip>'`, проверьте, что не осталось других записей `quote_user@host` кроме `quote_user@'%'`;
|
||||
- если используется host-специфичный аккаунт (`'quote_user'@'192.168.x.x'`), назначьте права и для него;
|
||||
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
|
||||
@@ -81,4 +81,4 @@ func main() {
|
||||
log.Println(" - reset-counters: Reset usage counters")
|
||||
log.Println(" - update-popularity: Update popularity scores")
|
||||
}
|
||||
}
|
||||
}
|
||||
139
cmd/qfs/main.go
139
cmd/qfs/main.go
@@ -7,14 +7,12 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -44,8 +42,6 @@ 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)")
|
||||
@@ -171,30 +167,11 @@ 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")
|
||||
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)
|
||||
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
|
||||
slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +188,7 @@ func main() {
|
||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||
defer workerCancel()
|
||||
|
||||
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
|
||||
syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute)
|
||||
go syncWorker.Start(workerCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
@@ -483,7 +460,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, pricingService)
|
||||
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo)
|
||||
} else {
|
||||
// In offline mode, we still need to create services that don't require DB
|
||||
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
|
||||
@@ -491,7 +468,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, nil)
|
||||
pricelistService = pricelist.NewService(nil, nil, nil)
|
||||
}
|
||||
|
||||
// isOnline function for local-first architecture
|
||||
@@ -584,7 +561,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, backgroundSyncInterval)
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||
}
|
||||
@@ -744,8 +721,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1045,32 +1020,10 @@ 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 {
|
||||
@@ -1090,69 +1043,12 @@ 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)
|
||||
}
|
||||
|
||||
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]
|
||||
projectRows := make([]gin.H, 0, len(filtered))
|
||||
for i := range filtered {
|
||||
p := filtered[i]
|
||||
configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active")
|
||||
if err != nil {
|
||||
configs = &services.ProjectConfigurationsResult{
|
||||
@@ -1176,16 +1072,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"projects": projectRows,
|
||||
"status": status,
|
||||
"search": search,
|
||||
"author": author,
|
||||
"sort": sortField,
|
||||
"dir": sortDir,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"total": total,
|
||||
"total_pages": totalPages,
|
||||
"projects": projectRows,
|
||||
"status": status,
|
||||
"search": search,
|
||||
"total": len(projectRows),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1360,7 +1250,6 @@ 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)
|
||||
|
||||
@@ -2,12 +2,9 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -42,18 +39,8 @@ type DatabaseConfig struct {
|
||||
}
|
||||
|
||||
func (d *DatabaseConfig) DSN() string {
|
||||
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()
|
||||
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)
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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 {
|
||||
@@ -23,19 +22,8 @@ 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"
|
||||
|
||||
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)
|
||||
}
|
||||
pricelists, total, err := h.service.List(page, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -123,74 +111,6 @@ 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()
|
||||
@@ -217,40 +137,6 @@ 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")
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,9 +13,8 @@ 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"
|
||||
gormmysql "gorm.io/driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@@ -95,9 +93,10 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||
user, password, host, port, database)
|
||||
|
||||
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -170,9 +169,10 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Test connection first
|
||||
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||
user, password, host, port, database)
|
||||
|
||||
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -254,19 +254,3 @@ 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()
|
||||
}
|
||||
|
||||
@@ -17,16 +17,14 @@ import (
|
||||
|
||||
// SyncHandler handles sync API endpoints
|
||||
type SyncHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
syncService *sync.Service
|
||||
connMgr *db.ConnectionManager
|
||||
autoSyncInterval time.Duration
|
||||
onlineGraceFactor float64
|
||||
tmpl *template.Template
|
||||
localDB *localdb.LocalDB
|
||||
syncService *sync.Service
|
||||
connMgr *db.ConnectionManager
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// NewSyncHandler creates a new sync handler
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) {
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
|
||||
// Load sync_status partial template
|
||||
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||
var tmpl *template.Template
|
||||
@@ -41,12 +39,10 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
||||
}
|
||||
|
||||
return &SyncHandler{
|
||||
localDB: localDB,
|
||||
syncService: syncService,
|
||||
connMgr: connMgr,
|
||||
autoSyncInterval: autoSyncInterval,
|
||||
onlineGraceFactor: 1.10,
|
||||
tmpl: tmpl,
|
||||
localDB: localDB,
|
||||
syncService: syncService,
|
||||
connMgr: connMgr,
|
||||
tmpl: tmpl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -177,7 +173,6 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
Synced: synced,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// SyncAllResponse represents result of full sync
|
||||
@@ -243,7 +238,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
PricelistsSynced: pricelistsSynced,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// checkOnline checks if MariaDB is accessible
|
||||
@@ -279,7 +273,6 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
|
||||
Synced: pushed,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
|
||||
// GetPendingCount returns the number of pending changes
|
||||
@@ -315,14 +308,6 @@ 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"`
|
||||
@@ -379,40 +364,6 @@ 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
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -28,7 +28,6 @@ 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(),
|
||||
@@ -71,7 +70,6 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
Notes: local.Notes,
|
||||
IsTemplate: local.IsTemplate,
|
||||
ServerCount: local.ServerCount,
|
||||
PricelistID: local.PricelistID,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
CreatedAt: local.CreatedAt,
|
||||
}
|
||||
|
||||
@@ -4,15 +4,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"github.com/glebarez/sqlite"
|
||||
uuidpkg "github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
@@ -144,23 +141,19 @@ func (l *LocalDB) GetDSN() (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
// 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,
|
||||
)
|
||||
|
||||
return cfg.FormatDSN(), nil
|
||||
return dsn, nil
|
||||
}
|
||||
|
||||
// DB returns the underlying gorm.DB for advanced operations
|
||||
@@ -537,15 +530,6 @@ 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
|
||||
@@ -621,25 +605,6 @@ 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
|
||||
|
||||
@@ -42,11 +42,6 @@ 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,
|
||||
},
|
||||
}
|
||||
|
||||
func runLocalMigrations(db *gorm.DB) error {
|
||||
@@ -197,40 +192,6 @@ 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
|
||||
|
||||
@@ -72,7 +72,6 @@ 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"`
|
||||
|
||||
@@ -22,7 +22,6 @@ 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,
|
||||
@@ -51,7 +50,6 @@ 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"`
|
||||
@@ -76,7 +74,6 @@ 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,
|
||||
|
||||
@@ -53,7 +53,6 @@ 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"`
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package models
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -24,30 +22,6 @@ 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 {
|
||||
@@ -55,11 +29,27 @@ func RunSQLMigrations(db *gorm.DB, migrationsDir string) error {
|
||||
return fmt.Errorf("migrate qt_schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
files, err := listSQLMigrationFiles(migrationsDir)
|
||||
entries, err := os.ReadDir(migrationsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return 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)
|
||||
|
||||
for _, filename := range files {
|
||||
var count int64
|
||||
if err := db.Model(&SQLSchemaMigration{}).Where("filename = ?", filename).Count(&count).Error; err != nil {
|
||||
@@ -94,37 +84,6 @@ 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 (
|
||||
@@ -198,30 +157,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -110,10 +110,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ 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,
|
||||
@@ -73,7 +72,7 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
|
||||
CreatedAt: pl.CreatedAt,
|
||||
CreatedBy: pl.CreatedBy,
|
||||
IsActive: pl.IsActive,
|
||||
UsageCount: int(usageCount),
|
||||
UsageCount: pl.UsageCount,
|
||||
ExpiresAt: pl.ExpiresAt,
|
||||
ItemCount: itemCount,
|
||||
}
|
||||
@@ -93,9 +92,6 @@ 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
|
||||
}
|
||||
@@ -136,13 +132,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 {
|
||||
usageCount, err := r.CountUsage(id)
|
||||
pricelist, err := r.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if usageCount > 0 {
|
||||
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount)
|
||||
if pricelist.UsageCount > 0 {
|
||||
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount)
|
||||
}
|
||||
|
||||
// Delete items first
|
||||
@@ -212,20 +208,6 @@ 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")
|
||||
@@ -313,15 +295,6 @@ 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
|
||||
|
||||
@@ -24,7 +24,6 @@ type ConfigurationService struct {
|
||||
configRepo *repository.ConfigurationRepository
|
||||
projectRepo *repository.ProjectRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
quoteService *QuoteService
|
||||
}
|
||||
|
||||
@@ -32,14 +31,12 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -52,7 +49,6 @@ 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) {
|
||||
@@ -60,10 +56,6 @@ 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()
|
||||
|
||||
@@ -83,7 +75,6 @@ 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 {
|
||||
@@ -124,10 +115,6 @@ 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()
|
||||
|
||||
@@ -144,7 +131,6 @@ 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
|
||||
@@ -221,7 +207,6 @@ 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 {
|
||||
@@ -276,10 +261,6 @@ 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 {
|
||||
@@ -294,7 +275,6 @@ 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
|
||||
@@ -361,7 +341,6 @@ 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 {
|
||||
@@ -391,23 +370,6 @@ 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)
|
||||
@@ -415,30 +377,8 @@ 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
|
||||
@@ -459,9 +399,6 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
if latestPricelistID != nil {
|
||||
config.PricelistID = latestPricelistID
|
||||
}
|
||||
now := time.Now()
|
||||
config.PriceUpdatedAt = &now
|
||||
|
||||
@@ -495,32 +432,10 @@ 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
|
||||
@@ -546,9 +461,6 @@ func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string)
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
if latestPricelistID != nil {
|
||||
config.PricelistID = latestPricelistID
|
||||
}
|
||||
|
||||
// Set price update timestamp
|
||||
now := time.Now()
|
||||
|
||||
@@ -59,10 +59,6 @@ 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 {
|
||||
@@ -80,7 +76,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
PricelistID: pricelistID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -133,10 +128,6 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
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 {
|
||||
@@ -159,7 +150,6 @@ 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"
|
||||
|
||||
@@ -264,7 +254,6 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -335,28 +324,10 @@ 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 {
|
||||
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
|
||||
// Get 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
|
||||
@@ -382,9 +353,6 @@ 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()
|
||||
@@ -422,10 +390,6 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
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 {
|
||||
@@ -447,7 +411,6 @@ 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"
|
||||
|
||||
@@ -539,7 +502,6 @@ func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newN
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -678,27 +640,10 @@ 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 {
|
||||
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
|
||||
// Get 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
|
||||
@@ -724,9 +669,6 @@ 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()
|
||||
@@ -873,9 +815,6 @@ 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
|
||||
})
|
||||
@@ -915,9 +854,6 @@ 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
|
||||
})
|
||||
@@ -1022,7 +958,6 @@ 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"
|
||||
@@ -1080,9 +1015,6 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, ¤t, "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
|
||||
})
|
||||
@@ -1106,7 +1038,6 @@ 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,
|
||||
@@ -1140,21 +1071,6 @@ 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 == "" {
|
||||
@@ -1200,25 +1116,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -9,79 +9,29 @@ 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
|
||||
}
|
||||
|
||||
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 {
|
||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *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
|
||||
@@ -151,7 +101,6 @@ func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgre
|
||||
"items", len(items),
|
||||
"created_by", createdBy,
|
||||
)
|
||||
report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs})
|
||||
|
||||
return pricelist, nil
|
||||
}
|
||||
@@ -181,21 +130,6 @@ 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 {
|
||||
@@ -227,22 +161,6 @@ 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 {
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
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(
|
||||
@@ -30,16 +19,10 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,183 +179,27 @@ type PriceStats struct {
|
||||
|
||||
// RecalculateAllPrices recalculates prices for all components
|
||||
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
|
||||
return s.RecalculateAllPricesWithProgress(nil)
|
||||
}
|
||||
// Get all components
|
||||
filter := repository.ComponentFilter{}
|
||||
offset := 0
|
||||
limit := 100
|
||||
|
||||
// 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 {
|
||||
components, _, err := s.componentRepo.List(filter, offset, limit)
|
||||
if err != nil || len(components) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
for _, comp := range components {
|
||||
if err := s.UpdateComponentPrice(comp.LotName); err != nil {
|
||||
errors++
|
||||
} else {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
offset += limit
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
@@ -50,13 +49,6 @@ 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"`
|
||||
@@ -71,7 +63,6 @@ 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"`
|
||||
@@ -309,104 +300,11 @@ 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)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
result := make([]UserSyncStatus, 0, len(rows))
|
||||
for i := range rows {
|
||||
r := rows[i]
|
||||
result = append(result, UserSyncStatus{
|
||||
Username: r.Username,
|
||||
LastSyncAt: r.LastSyncAt,
|
||||
AppVersion: strings.TrimSpace(r.AppVersion),
|
||||
IsOnline: now.Sub(r.LastSyncAt) <= onlineThreshold,
|
||||
})
|
||||
}
|
||||
|
||||
return result, 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
|
||||
@@ -712,9 +610,6 @@ 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 {
|
||||
@@ -773,9 +668,6 @@ 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
|
||||
@@ -786,34 +678,15 @@ 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.
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
cfg.ID = serverCfg.ID
|
||||
|
||||
// Update local with server ID
|
||||
serverID := cfg.ID
|
||||
serverID := serverCfg.ID
|
||||
localCfg.ServerID = &serverID
|
||||
s.localDB.SaveConfiguration(localCfg)
|
||||
} else {
|
||||
@@ -928,29 +801,6 @@ 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)
|
||||
@@ -998,7 +848,6 @@ func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChan
|
||||
if currentVersionNo > 0 {
|
||||
payload.CurrentVersionNo = currentVersionNo
|
||||
}
|
||||
payload.PricelistID = currentCfg.PricelistID
|
||||
}
|
||||
|
||||
isStale := false
|
||||
@@ -1036,7 +885,6 @@ 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,
|
||||
|
||||
@@ -202,57 +202,6 @@ 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")
|
||||
@@ -299,7 +248,6 @@ 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 {
|
||||
|
||||
@@ -83,11 +83,7 @@ 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")
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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)
|
||||
);
|
||||
@@ -1,37 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE qt_pricelist_sync_status
|
||||
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;
|
||||
@@ -1,51 +0,0 @@
|
||||
# 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
|
||||
@@ -10,18 +10,17 @@
|
||||
<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">
|
||||
@@ -86,30 +85,6 @@
|
||||
<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">
|
||||
@@ -119,16 +94,6 @@
|
||||
Автор прайслиста: <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">
|
||||
@@ -251,21 +216,16 @@ 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
|
||||
@@ -274,69 +234,35 @@ 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' && tab !== 'sync-status') {
|
||||
if (tab !== 'pricelists') {
|
||||
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>';
|
||||
|
||||
@@ -824,11 +750,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 = 'Подготовка...';
|
||||
|
||||
@@ -843,7 +769,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(() => {
|
||||
@@ -868,7 +794,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 : 'Обработка компонентов...';
|
||||
@@ -890,7 +816,7 @@ function recalculateAll() {
|
||||
console.error('Fetch error:', e);
|
||||
alert('Ошибка соединения');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Обновить цены';
|
||||
btn.textContent = 'Пересчитать цены';
|
||||
progressContainer.style.display = 'none';
|
||||
});
|
||||
}
|
||||
@@ -966,12 +892,11 @@ function renderAllConfigs(configs) {
|
||||
document.getElementById('tab-content').innerHTML = html;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await checkPricelistWritePermission();
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Check URL params for initial tab
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialTab = urlParams.get('tab') || 'alerts';
|
||||
await loadTab(initialTab);
|
||||
loadTab(initialTab);
|
||||
|
||||
// Add event listeners for preview updates
|
||||
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
||||
@@ -995,89 +920,9 @@ 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1120,10 +965,6 @@ 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>`;
|
||||
}
|
||||
@@ -1148,33 +989,6 @@ 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) {
|
||||
@@ -1210,7 +1024,6 @@ async function loadPricelistsDbUsername() {
|
||||
function openPricelistsCreateModal() {
|
||||
document.getElementById('pricelists-create-modal').classList.remove('hidden');
|
||||
document.getElementById('pricelists-create-modal').classList.add('flex');
|
||||
resetPricelistCreateProgress();
|
||||
loadPricelistsDbUsername();
|
||||
}
|
||||
|
||||
@@ -1236,88 +1049,20 @@ async function createPricelist() {
|
||||
throw new Error('Создание прайслистов доступно только в онлайн режиме');
|
||||
}
|
||||
|
||||
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'
|
||||
const resp = await fetch('/api/pricelists', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
let data = {};
|
||||
try { data = await resp.json(); } catch (_) {}
|
||||
const data = await resp.json();
|
||||
throw new Error(data.error || 'Failed to create pricelist');
|
||||
}
|
||||
|
||||
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 = '';
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async function deletePricelist(id) {
|
||||
|
||||
@@ -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,14 +34,6 @@
|
||||
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>
|
||||
@@ -232,7 +224,6 @@ 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;
|
||||
@@ -305,7 +296,6 @@ 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 => ({
|
||||
@@ -332,7 +322,6 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadActivePricelists();
|
||||
await loadAllComponents();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
@@ -372,44 +361,6 @@ 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] || '';
|
||||
@@ -1182,8 +1133,7 @@ async function saveConfig(showNotification = true) {
|
||||
items: cart,
|
||||
custom_price: customPrice,
|
||||
notes: '',
|
||||
server_count: serverCountValue,
|
||||
pricelist_id: selectedPricelistId
|
||||
server_count: serverCountValue
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1377,17 +1327,6 @@ 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();
|
||||
|
||||
@@ -147,15 +147,12 @@
|
||||
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 (method === 'average') {
|
||||
} else if (item.price_method === 'average') {
|
||||
settings.push('Сред');
|
||||
} else if (method === 'weighted_median') {
|
||||
settings.push('Взвеш. мед');
|
||||
} else {
|
||||
settings.push('Мед');
|
||||
}
|
||||
|
||||
@@ -30,11 +30,6 @@
|
||||
<script>
|
||||
let status = 'active';
|
||||
let projectsSearch = '';
|
||||
let authorSearch = '';
|
||||
let currentPage = 1;
|
||||
let perPage = 10;
|
||||
let sortField = 'created_at';
|
||||
let sortDir = 'desc';
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
@@ -46,33 +41,8 @@ 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';
|
||||
@@ -87,73 +57,36 @@ async function loadProjects() {
|
||||
root.innerHTML = '<div class="text-gray-500">Загрузка...</div>';
|
||||
|
||||
let rows = [];
|
||||
let total = 0;
|
||||
let totalPages = 0;
|
||||
let page = currentPage;
|
||||
try {
|
||||
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());
|
||||
const resp = await fetch('/api/projects?status=' + status + '&search=' + encodeURIComponent(projectsSearch));
|
||||
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">';
|
||||
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 += '<thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название проекта</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
|
||||
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>';
|
||||
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>';
|
||||
}
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
|
||||
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">';
|
||||
@@ -184,38 +117,7 @@ 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') + '">←</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') + '">→</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();
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
@@ -321,7 +223,6 @@ loadProjects();
|
||||
|
||||
document.getElementById('projects-search').addEventListener('input', function(e) {
|
||||
projectsSearch = (e.target.value || '').trim();
|
||||
currentPage = 1;
|
||||
loadProjects();
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user