Compare commits
7 Commits
65871a8b04
...
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
|
# Local Go build cache used in sandboxed runs
|
||||||
.gocache/
|
.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
|
# ---> macOS
|
||||||
# General
|
# General
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -113,52 +113,30 @@ go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
|
|||||||
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
|
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- 1) Создать пользователя (если его ещё нет)
|
-- 1) Создать (или оставить существующего) пользователя
|
||||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||||
|
|
||||||
-- 2) Если пользователь уже существовал, принудительно обновить пароль
|
-- 2) Сбросить лишние права (без пересоздания пользователя)
|
||||||
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
|
||||||
|
|
||||||
-- 3) (Опционально, но рекомендуется) удалить дубли пользователя с другими host,
|
|
||||||
-- чтобы не возникало конфликтов вида user@localhost vs user@'%'
|
|
||||||
DROP USER IF EXISTS 'quote_user'@'localhost';
|
|
||||||
DROP USER IF EXISTS 'quote_user'@'127.0.0.1';
|
|
||||||
DROP USER IF EXISTS 'quote_user'@'::1';
|
|
||||||
|
|
||||||
-- 4) Сбросить лишние права
|
|
||||||
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
|
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
|
||||||
|
|
||||||
-- 5) Чтение данных для конфигуратора и синка
|
-- 3) Чтение данных для конфигуратора и синка
|
||||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
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_lot_metadata TO 'quote_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_categories 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_pricelists TO 'quote_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items 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'@'%';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||||
|
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
SHOW GRANTS FOR 'quote_user'@'%';
|
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`, если пользователь не должен управлять прайслистами;
|
- не выдавайте `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` приложение перезапускается автоматически и подхватывает нового пользователя.
|
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
|
||||||
|
|
||||||
### 4. Импорт метаданных компонентов
|
### 4. Импорт метаданных компонентов
|
||||||
|
|||||||
@@ -81,4 +81,4 @@ func main() {
|
|||||||
log.Println(" - reset-counters: Reset usage counters")
|
log.Println(" - reset-counters: Reset usage counters")
|
||||||
log.Println(" - update-popularity: Update popularity scores")
|
log.Println(" - update-popularity: Update popularity scores")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
185
cmd/qfs/main.go
185
cmd/qfs/main.go
@@ -7,14 +7,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -44,8 +42,6 @@ import (
|
|||||||
// Version is set via ldflags during build
|
// Version is set via ldflags during build
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
const backgroundSyncInterval = 5 * time.Minute
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
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)")
|
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.
|
// Always apply SQL migrations on startup when database is available.
|
||||||
// This keeps schema in sync for long-running installations without manual steps.
|
// 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 {
|
if mariaDB != nil {
|
||||||
sqlMigrationsPath := filepath.Join("migrations")
|
sqlMigrationsPath := filepath.Join("migrations")
|
||||||
needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath)
|
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
|
||||||
if err != nil {
|
slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err)
|
||||||
if models.IsMigrationPermissionError(err) {
|
os.Exit(1)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +188,7 @@ func main() {
|
|||||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||||
defer workerCancel()
|
defer workerCancel()
|
||||||
|
|
||||||
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
|
syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute)
|
||||||
go syncWorker.Start(workerCtx)
|
go syncWorker.Start(workerCtx)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -480,18 +457,18 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if mariaDB != nil {
|
if mariaDB != nil {
|
||||||
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||||
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, pricingService)
|
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||||
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
||||||
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
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 {
|
} else {
|
||||||
// In offline mode, we still need to create services that don't require DB
|
// In offline mode, we still need to create services that don't require DB
|
||||||
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
|
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
|
||||||
componentService = services.NewComponentService(nil, nil, nil)
|
componentService = services.NewComponentService(nil, nil, nil)
|
||||||
quoteService = services.NewQuoteService(nil, nil, nil, local, pricingService)
|
quoteService = services.NewQuoteService(nil, nil, pricingService)
|
||||||
exportService = services.NewExportService(cfg.Export, nil)
|
exportService = services.NewExportService(cfg.Export, nil)
|
||||||
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
|
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
|
// isOnline function for local-first architecture
|
||||||
@@ -527,8 +504,44 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if !connMgr.IsOnline() {
|
if !connMgr.IsOnline() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
|
serverDB, err := connMgr.GetDB()
|
||||||
slog.Warn("failed to sync projects from server", "error", err)
|
if err != nil || serverDB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectRepo := repository.NewProjectRepository(serverDB)
|
||||||
|
serverProjects, _, err := projectRepo.List(0, 10000, true)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for i := range serverProjects {
|
||||||
|
sp := serverProjects[i]
|
||||||
|
localProject, getErr := local.GetProjectByUUID(sp.UUID)
|
||||||
|
if getErr == nil && localProject != nil {
|
||||||
|
// Keep unsynced local changes intact.
|
||||||
|
if localProject.SyncStatus == "pending" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localProject.OwnerUsername = sp.OwnerUsername
|
||||||
|
localProject.Name = sp.Name
|
||||||
|
localProject.IsActive = sp.IsActive
|
||||||
|
localProject.IsSystem = sp.IsSystem
|
||||||
|
localProject.CreatedAt = sp.CreatedAt
|
||||||
|
localProject.UpdatedAt = sp.UpdatedAt
|
||||||
|
serverID := sp.ID
|
||||||
|
localProject.ServerID = &serverID
|
||||||
|
localProject.SyncStatus = "synced"
|
||||||
|
localProject.SyncedAt = &now
|
||||||
|
_ = local.SaveProject(localProject)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lp := localdb.ProjectToLocal(&sp)
|
||||||
|
lp.SyncStatus = "synced"
|
||||||
|
lp.SyncedAt = &now
|
||||||
|
_ = local.SaveProject(lp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,7 +561,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||||
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
||||||
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
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 {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||||
}
|
}
|
||||||
@@ -691,7 +704,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
{
|
{
|
||||||
quote.POST("/validate", quoteHandler.Validate)
|
quote.POST("/validate", quoteHandler.Validate)
|
||||||
quote.POST("/calculate", quoteHandler.Calculate)
|
quote.POST("/calculate", quoteHandler.Calculate)
|
||||||
quote.POST("/price-levels", quoteHandler.PriceLevels)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export (public)
|
// Export (public)
|
||||||
@@ -709,8 +721,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
pricelists.GET("/:id", pricelistHandler.Get)
|
pricelists.GET("/:id", pricelistHandler.Get)
|
||||||
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
||||||
pricelists.POST("", pricelistHandler.Create)
|
pricelists.POST("", pricelistHandler.Create)
|
||||||
pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
|
|
||||||
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
|
|
||||||
pricelists.DELETE("/:id", pricelistHandler.Delete)
|
pricelists.DELETE("/:id", pricelistHandler.Delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1010,32 +1020,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
|
|
||||||
status := c.DefaultQuery("status", "active")
|
status := c.DefaultQuery("status", "active")
|
||||||
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
|
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" {
|
if status != "active" && status != "archived" && status != "all" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
||||||
return
|
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)
|
allProjects, err := projectService.ListByUser(dbUsername, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1055,69 +1043,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
|
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, p)
|
filtered = append(filtered, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(filtered, func(i, j int) bool {
|
projectRows := make([]gin.H, 0, len(filtered))
|
||||||
left := filtered[i]
|
for i := range filtered {
|
||||||
right := filtered[j]
|
p := filtered[i]
|
||||||
if sortField == "name" {
|
|
||||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
|
||||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
|
||||||
if leftName == rightName {
|
|
||||||
if sortDir == "asc" {
|
|
||||||
return left.CreatedAt.Before(right.CreatedAt)
|
|
||||||
}
|
|
||||||
return left.CreatedAt.After(right.CreatedAt)
|
|
||||||
}
|
|
||||||
if sortDir == "asc" {
|
|
||||||
return leftName < rightName
|
|
||||||
}
|
|
||||||
return leftName > rightName
|
|
||||||
}
|
|
||||||
if left.CreatedAt.Equal(right.CreatedAt) {
|
|
||||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
|
||||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
|
||||||
if sortDir == "asc" {
|
|
||||||
return leftName < rightName
|
|
||||||
}
|
|
||||||
return leftName > rightName
|
|
||||||
}
|
|
||||||
if sortDir == "asc" {
|
|
||||||
return left.CreatedAt.Before(right.CreatedAt)
|
|
||||||
}
|
|
||||||
return left.CreatedAt.After(right.CreatedAt)
|
|
||||||
})
|
|
||||||
|
|
||||||
total := len(filtered)
|
|
||||||
totalPages := 0
|
|
||||||
if total > 0 {
|
|
||||||
totalPages = int(math.Ceil(float64(total) / float64(perPage)))
|
|
||||||
}
|
|
||||||
if totalPages > 0 && page > totalPages {
|
|
||||||
page = totalPages
|
|
||||||
}
|
|
||||||
|
|
||||||
start := (page - 1) * perPage
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
end := start + perPage
|
|
||||||
if end > total {
|
|
||||||
end = total
|
|
||||||
}
|
|
||||||
|
|
||||||
paged := []models.Project{}
|
|
||||||
if start < total {
|
|
||||||
paged = filtered[start:end]
|
|
||||||
}
|
|
||||||
|
|
||||||
projectRows := make([]gin.H, 0, len(paged))
|
|
||||||
for i := range paged {
|
|
||||||
p := paged[i]
|
|
||||||
configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active")
|
configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
configs = &services.ProjectConfigurationsResult{
|
configs = &services.ProjectConfigurationsResult{
|
||||||
@@ -1131,7 +1062,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
"uuid": p.UUID,
|
"uuid": p.UUID,
|
||||||
"owner_username": p.OwnerUsername,
|
"owner_username": p.OwnerUsername,
|
||||||
"name": p.Name,
|
"name": p.Name,
|
||||||
"tracker_url": p.TrackerURL,
|
|
||||||
"is_active": p.IsActive,
|
"is_active": p.IsActive,
|
||||||
"is_system": p.IsSystem,
|
"is_system": p.IsSystem,
|
||||||
"created_at": p.CreatedAt,
|
"created_at": p.CreatedAt,
|
||||||
@@ -1142,16 +1072,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"projects": projectRows,
|
"projects": projectRows,
|
||||||
"status": status,
|
"status": status,
|
||||||
"search": search,
|
"search": search,
|
||||||
"author": author,
|
"total": len(projectRows),
|
||||||
"sort": sortField,
|
|
||||||
"dir": sortDir,
|
|
||||||
"page": page,
|
|
||||||
"per_page": perPage,
|
|
||||||
"total": total,
|
|
||||||
"total_pages": totalPages,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1326,7 +1250,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
{
|
{
|
||||||
syncAPI.GET("/status", syncHandler.GetStatus)
|
syncAPI.GET("/status", syncHandler.GetStatus)
|
||||||
syncAPI.GET("/info", syncHandler.GetInfo)
|
syncAPI.GET("/info", syncHandler.GetInfo)
|
||||||
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
|
|
||||||
syncAPI.POST("/components", syncHandler.SyncComponents)
|
syncAPI.POST("/components", syncHandler.SyncComponents)
|
||||||
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
||||||
syncAPI.POST("/all", syncHandler.SyncAll)
|
syncAPI.POST("/all", syncHandler.SyncAll)
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,18 +39,8 @@ type DatabaseConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DatabaseConfig) DSN() string {
|
func (d *DatabaseConfig) DSN() string {
|
||||||
cfg := mysqlDriver.NewConfig()
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
cfg.User = d.User
|
d.User, d.Password, d.Host, d.Port, d.Name)
|
||||||
cfg.Passwd = d.Password
|
|
||||||
cfg.Net = "tcp"
|
|
||||||
cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
|
|
||||||
cfg.DBName = d.Name
|
|
||||||
cfg.ParseTime = true
|
|
||||||
cfg.Loc = time.Local
|
|
||||||
cfg.Params = map[string]string{
|
|
||||||
"charset": "utf8mb4",
|
|
||||||
}
|
|
||||||
return cfg.FormatDSN()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PricelistHandler struct {
|
type PricelistHandler struct {
|
||||||
@@ -26,20 +22,8 @@ func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *
|
|||||||
func (h *PricelistHandler) List(c *gin.Context) {
|
func (h *PricelistHandler) List(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
activeOnly := c.DefaultQuery("active_only", "false") == "true"
|
|
||||||
source := c.Query("source")
|
|
||||||
|
|
||||||
var (
|
pricelists, total, err := h.service.List(page, perPage)
|
||||||
pricelists any
|
|
||||||
total int64
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
if activeOnly {
|
|
||||||
pricelists, total, err = h.service.ListActiveBySource(page, perPage, source)
|
|
||||||
} else {
|
|
||||||
pricelists, total, err = h.service.ListBySource(page, perPage, source)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -49,21 +33,11 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
|||||||
if total == 0 && h.localDB != nil {
|
if total == 0 && h.localDB != nil {
|
||||||
localPLs, err := h.localDB.GetLocalPricelists()
|
localPLs, err := h.localDB.GetLocalPricelists()
|
||||||
if err == nil && len(localPLs) > 0 {
|
if err == nil && len(localPLs) > 0 {
|
||||||
if source != "" {
|
|
||||||
filtered := localPLs[:0]
|
|
||||||
for _, lpl := range localPLs {
|
|
||||||
if lpl.Source == source {
|
|
||||||
filtered = append(filtered, lpl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
localPLs = filtered
|
|
||||||
}
|
|
||||||
// Convert to PricelistSummary format
|
// Convert to PricelistSummary format
|
||||||
summaries := make([]map[string]interface{}, len(localPLs))
|
summaries := make([]map[string]interface{}, len(localPLs))
|
||||||
for i, lpl := range localPLs {
|
for i, lpl := range localPLs {
|
||||||
summaries[i] = map[string]interface{}{
|
summaries[i] = map[string]interface{}{
|
||||||
"id": lpl.ServerID,
|
"id": lpl.ServerID,
|
||||||
"source": lpl.Source,
|
|
||||||
"version": lpl.Version,
|
"version": lpl.Version,
|
||||||
"created_by": "sync",
|
"created_by": "sync",
|
||||||
"item_count": 0, // Not tracked
|
"item_count": 0, // Not tracked
|
||||||
@@ -122,33 +96,13 @@ func (h *PricelistHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
Items []struct {
|
|
||||||
LotName string `json:"lot_name"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
} `json:"items"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source := string(models.NormalizePricelistSource(req.Source))
|
|
||||||
|
|
||||||
// Get the database username as the creator
|
// Get the database username as the creator
|
||||||
createdBy := h.localDB.GetDBUser()
|
createdBy := h.localDB.GetDBUser()
|
||||||
if createdBy == "" {
|
if createdBy == "" {
|
||||||
createdBy = "unknown"
|
createdBy = "unknown"
|
||||||
}
|
}
|
||||||
sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items))
|
|
||||||
for _, item := range req.Items {
|
|
||||||
sourceItems = append(sourceItems, pricelist.CreateItemInput{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Price: item.Price,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
|
pl, err := h.service.CreateFromCurrentPrices(createdBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -157,94 +111,6 @@ func (h *PricelistHandler) Create(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, pl)
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
Items []struct {
|
|
||||||
LotName string `json:"lot_name"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
} `json:"items"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source := string(models.NormalizePricelistSource(req.Source))
|
|
||||||
|
|
||||||
createdBy := h.localDB.GetDBUser()
|
|
||||||
if createdBy == "" {
|
|
||||||
createdBy = "unknown"
|
|
||||||
}
|
|
||||||
sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items))
|
|
||||||
for _, item := range req.Items {
|
|
||||||
sourceItems = append(sourceItems, pricelist.CreateItemInput{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Price: item.Price,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
|
|
||||||
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.CreateForSourceWithProgress(createdBy, source, sourceItems, 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
|
// Delete deletes a pricelist by ID
|
||||||
func (h *PricelistHandler) Delete(c *gin.Context) {
|
func (h *PricelistHandler) Delete(c *gin.Context) {
|
||||||
canWrite, debugInfo := h.service.CanWriteDebug()
|
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||||
@@ -271,40 +137,6 @@ func (h *PricelistHandler) Delete(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
|
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
|
// GetItems returns items for a pricelist with pagination
|
||||||
func (h *PricelistHandler) GetItems(c *gin.Context) {
|
func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
@@ -323,14 +155,8 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pl, _ := h.service.GetByID(uint(id))
|
|
||||||
source := ""
|
|
||||||
if pl != nil {
|
|
||||||
source = pl.Source
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"source": source,
|
|
||||||
"items": items,
|
"items": items,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
@@ -346,18 +172,15 @@ func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
|||||||
|
|
||||||
// GetLatest returns the most recent active pricelist
|
// GetLatest returns the most recent active pricelist
|
||||||
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||||
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
|
|
||||||
source = string(models.NormalizePricelistSource(source))
|
|
||||||
|
|
||||||
// Try to get from server first
|
// Try to get from server first
|
||||||
pl, err := h.service.GetLatestActiveBySource(source)
|
pl, err := h.service.GetLatestActive()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If offline or no server pricelists, try to get from local cache
|
// If offline or no server pricelists, try to get from local cache
|
||||||
if h.localDB == nil {
|
if h.localDB == nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
localPL, localErr := h.localDB.GetLatestLocalPricelistBySource(source)
|
localPL, localErr := h.localDB.GetLatestLocalPricelist()
|
||||||
if localErr != nil {
|
if localErr != nil {
|
||||||
// No local pricelists either
|
// No local pricelists either
|
||||||
c.JSON(http.StatusNotFound, gin.H{
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
@@ -369,7 +192,6 @@ func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
|||||||
// Return local pricelist
|
// Return local pricelist
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": localPL.ServerID,
|
"id": localPL.ServerID,
|
||||||
"source": localPL.Source,
|
|
||||||
"version": localPL.Version,
|
"version": localPL.Version,
|
||||||
"created_by": "sync",
|
"created_by": "sync",
|
||||||
"item_count": 0, // Not tracked in local pricelists
|
"item_count": 0, // Not tracked in local pricelists
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QuoteHandler struct {
|
type QuoteHandler struct {
|
||||||
@@ -49,19 +49,3 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
|
|||||||
"total": result.Total,
|
"total": result.Total,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
|
|
||||||
var req services.PriceLevelsRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.quoteService.CalculatePriceLevels(&req)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -14,9 +13,8 @@ import (
|
|||||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
gormmysql "gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"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),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -170,9 +169,10 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test connection first
|
// 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),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -254,19 +254,3 @@ func testWritePermission(db *gorm.DB) bool {
|
|||||||
|
|
||||||
return true
|
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
|
// SyncHandler handles sync API endpoints
|
||||||
type SyncHandler struct {
|
type SyncHandler struct {
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
syncService *sync.Service
|
syncService *sync.Service
|
||||||
connMgr *db.ConnectionManager
|
connMgr *db.ConnectionManager
|
||||||
autoSyncInterval time.Duration
|
tmpl *template.Template
|
||||||
onlineGraceFactor float64
|
|
||||||
tmpl *template.Template
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSyncHandler creates a new sync handler
|
// 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
|
// Load sync_status partial template
|
||||||
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
@@ -41,12 +39,10 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &SyncHandler{
|
return &SyncHandler{
|
||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
connMgr: connMgr,
|
connMgr: connMgr,
|
||||||
autoSyncInterval: autoSyncInterval,
|
tmpl: tmpl,
|
||||||
onlineGraceFactor: 1.10,
|
|
||||||
tmpl: tmpl,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,28 +173,18 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
|||||||
Synced: synced,
|
Synced: synced,
|
||||||
Duration: time.Since(startTime).String(),
|
Duration: time.Since(startTime).String(),
|
||||||
})
|
})
|
||||||
h.syncService.RecordSyncHeartbeat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncAllResponse represents result of full sync
|
// SyncAllResponse represents result of full sync
|
||||||
type SyncAllResponse struct {
|
type SyncAllResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
PendingPushed int `json:"pending_pushed"`
|
ComponentsSynced int `json:"components_synced"`
|
||||||
ComponentsSynced int `json:"components_synced"`
|
PricelistsSynced int `json:"pricelists_synced"`
|
||||||
PricelistsSynced int `json:"pricelists_synced"`
|
Duration string `json:"duration"`
|
||||||
ProjectsImported int `json:"projects_imported"`
|
|
||||||
ProjectsUpdated int `json:"projects_updated"`
|
|
||||||
ProjectsSkipped int `json:"projects_skipped"`
|
|
||||||
ConfigurationsImported int `json:"configurations_imported"`
|
|
||||||
ConfigurationsUpdated int `json:"configurations_updated"`
|
|
||||||
ConfigurationsSkipped int `json:"configurations_skipped"`
|
|
||||||
Duration string `json:"duration"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncAll performs full bidirectional sync:
|
// SyncAll syncs both components and pricelists
|
||||||
// - push pending local changes (projects/configurations) to server
|
|
||||||
// - pull components, pricelists, projects, and configurations from server
|
|
||||||
// POST /api/sync/all
|
// POST /api/sync/all
|
||||||
func (h *SyncHandler) SyncAll(c *gin.Context) {
|
func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||||
if !h.checkOnline() {
|
if !h.checkOnline() {
|
||||||
@@ -210,18 +196,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
var pendingPushed, componentsSynced, pricelistsSynced int
|
var componentsSynced, pricelistsSynced int
|
||||||
|
|
||||||
// Push local pending changes first (projects/configurations)
|
|
||||||
pendingPushed, err := h.syncService.PushPendingChanges()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("pending push failed during full sync", "error", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": "Pending changes push failed: " + err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync components
|
// Sync components
|
||||||
mariaDB, err := h.connMgr.GetDB()
|
mariaDB, err := h.connMgr.GetDB()
|
||||||
@@ -251,56 +226,18 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "Pricelist sync failed: " + err.Error(),
|
"error": "Pricelist sync failed: " + err.Error(),
|
||||||
"pending_pushed": pendingPushed,
|
|
||||||
"components_synced": componentsSynced,
|
"components_synced": componentsSynced,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
projectsResult, err := h.syncService.ImportProjectsToLocal()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("project import failed during full sync", "error", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": "Project import failed: " + err.Error(),
|
|
||||||
"pending_pushed": pendingPushed,
|
|
||||||
"components_synced": componentsSynced,
|
|
||||||
"pricelists_synced": pricelistsSynced,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
configsResult, err := h.syncService.ImportConfigurationsToLocal()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("configuration import failed during full sync", "error", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"success": false,
|
|
||||||
"error": "Configuration import failed: " + err.Error(),
|
|
||||||
"pending_pushed": pendingPushed,
|
|
||||||
"components_synced": componentsSynced,
|
|
||||||
"pricelists_synced": pricelistsSynced,
|
|
||||||
"projects_imported": projectsResult.Imported,
|
|
||||||
"projects_updated": projectsResult.Updated,
|
|
||||||
"projects_skipped": projectsResult.Skipped,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncAllResponse{
|
c.JSON(http.StatusOK, SyncAllResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Full sync completed successfully",
|
Message: "Full sync completed successfully",
|
||||||
PendingPushed: pendingPushed,
|
ComponentsSynced: componentsSynced,
|
||||||
ComponentsSynced: componentsSynced,
|
PricelistsSynced: pricelistsSynced,
|
||||||
PricelistsSynced: pricelistsSynced,
|
Duration: time.Since(startTime).String(),
|
||||||
ProjectsImported: projectsResult.Imported,
|
|
||||||
ProjectsUpdated: projectsResult.Updated,
|
|
||||||
ProjectsSkipped: projectsResult.Skipped,
|
|
||||||
ConfigurationsImported: configsResult.Imported,
|
|
||||||
ConfigurationsUpdated: configsResult.Updated,
|
|
||||||
ConfigurationsSkipped: configsResult.Skipped,
|
|
||||||
Duration: time.Since(startTime).String(),
|
|
||||||
})
|
})
|
||||||
h.syncService.RecordSyncHeartbeat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkOnline checks if MariaDB is accessible
|
// checkOnline checks if MariaDB is accessible
|
||||||
@@ -336,7 +273,6 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
|
|||||||
Synced: pushed,
|
Synced: pushed,
|
||||||
Duration: time.Since(startTime).String(),
|
Duration: time.Since(startTime).String(),
|
||||||
})
|
})
|
||||||
h.syncService.RecordSyncHeartbeat()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPendingCount returns the number of pending changes
|
// GetPendingCount returns the number of pending changes
|
||||||
@@ -372,14 +308,6 @@ type SyncInfoResponse struct {
|
|||||||
Errors []SyncError `json:"errors,omitempty"`
|
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
|
// SyncError represents a sync error
|
||||||
type SyncError struct {
|
type SyncError struct {
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
@@ -436,43 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep current client heartbeat fresh so app version is available in the table.
|
|
||||||
h.syncService.RecordSyncHeartbeat()
|
|
||||||
|
|
||||||
users, err := h.syncService.ListUserSyncStatuses(threshold)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncUsersStatusResponse{
|
|
||||||
IsOnline: true,
|
|
||||||
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
|
|
||||||
OnlineThresholdSeconds: int64(threshold.Seconds()),
|
|
||||||
GeneratedAt: time.Now().UTC(),
|
|
||||||
Users: users,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncStatusPartial renders the sync status partial for htmx
|
// SyncStatusPartial renders the sync status partial for htmx
|
||||||
// GET /partials/sync-status
|
// GET /partials/sync-status
|
||||||
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||||
|
|||||||
@@ -385,9 +385,10 @@ func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have components but no prices, load from latest estimate pricelist.
|
// If we have components but no prices, we should load prices from pricelists
|
||||||
|
// Find the latest pricelist
|
||||||
var latestPricelist LocalPricelist
|
var latestPricelist LocalPricelist
|
||||||
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&latestPricelist).Error; err != nil {
|
if err := l.db.Order("created_at DESC").First(&latestPricelist).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
slog.Warn("no pricelists found in local database")
|
slog.Warn("no pricelists found in local database")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
Notes: cfg.Notes,
|
Notes: cfg.Notes,
|
||||||
IsTemplate: cfg.IsTemplate,
|
IsTemplate: cfg.IsTemplate,
|
||||||
ServerCount: cfg.ServerCount,
|
ServerCount: cfg.ServerCount,
|
||||||
PricelistID: cfg.PricelistID,
|
|
||||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||||
CreatedAt: cfg.CreatedAt,
|
CreatedAt: cfg.CreatedAt,
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
@@ -71,7 +70,6 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
Notes: local.Notes,
|
Notes: local.Notes,
|
||||||
IsTemplate: local.IsTemplate,
|
IsTemplate: local.IsTemplate,
|
||||||
ServerCount: local.ServerCount,
|
ServerCount: local.ServerCount,
|
||||||
PricelistID: local.PricelistID,
|
|
||||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||||
CreatedAt: local.CreatedAt,
|
CreatedAt: local.CreatedAt,
|
||||||
}
|
}
|
||||||
@@ -99,7 +97,6 @@ func ProjectToLocal(project *models.Project) *LocalProject {
|
|||||||
UUID: project.UUID,
|
UUID: project.UUID,
|
||||||
OwnerUsername: project.OwnerUsername,
|
OwnerUsername: project.OwnerUsername,
|
||||||
Name: project.Name,
|
Name: project.Name,
|
||||||
TrackerURL: project.TrackerURL,
|
|
||||||
IsActive: project.IsActive,
|
IsActive: project.IsActive,
|
||||||
IsSystem: project.IsSystem,
|
IsSystem: project.IsSystem,
|
||||||
CreatedAt: project.CreatedAt,
|
CreatedAt: project.CreatedAt,
|
||||||
@@ -118,7 +115,6 @@ func LocalToProject(local *LocalProject) *models.Project {
|
|||||||
UUID: local.UUID,
|
UUID: local.UUID,
|
||||||
OwnerUsername: local.OwnerUsername,
|
OwnerUsername: local.OwnerUsername,
|
||||||
Name: local.Name,
|
Name: local.Name,
|
||||||
TrackerURL: local.TrackerURL,
|
|
||||||
IsActive: local.IsActive,
|
IsActive: local.IsActive,
|
||||||
IsSystem: local.IsSystem,
|
IsSystem: local.IsSystem,
|
||||||
CreatedAt: local.CreatedAt,
|
CreatedAt: local.CreatedAt,
|
||||||
@@ -139,7 +135,6 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
|||||||
|
|
||||||
return &LocalPricelist{
|
return &LocalPricelist{
|
||||||
ServerID: pl.ID,
|
ServerID: pl.ID,
|
||||||
Source: pl.Source,
|
|
||||||
Version: pl.Version,
|
Version: pl.Version,
|
||||||
Name: name,
|
Name: name,
|
||||||
CreatedAt: pl.CreatedAt,
|
CreatedAt: pl.CreatedAt,
|
||||||
@@ -152,7 +147,6 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
|||||||
func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
|
func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
|
||||||
return &models.Pricelist{
|
return &models.Pricelist{
|
||||||
ID: local.ServerID,
|
ID: local.ServerID,
|
||||||
Source: local.Source,
|
|
||||||
Version: local.Version,
|
Version: local.Version,
|
||||||
Notification: local.Name,
|
Notification: local.Name,
|
||||||
CreatedAt: local.CreatedAt,
|
CreatedAt: local.CreatedAt,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package localdb
|
|||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||||
@@ -71,57 +70,3 @@ func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
|||||||
t.Fatalf("expected local migrations to be recorded")
|
t.Fatalf("expected local migrations to be recorded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "pricelist_index_fix.db")
|
|
||||||
|
|
||||||
local, err := New(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open localdb: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = local.Close() })
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelist(&LocalPricelist{
|
|
||||||
ServerID: 10,
|
|
||||||
Version: "2026-02-06-001",
|
|
||||||
Name: "v1",
|
|
||||||
CreatedAt: time.Now().Add(-time.Hour),
|
|
||||||
SyncedAt: time.Now().Add(-time.Hour),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save first pricelist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.DB().Exec(`
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_version_legacy
|
|
||||||
ON local_pricelists(version)
|
|
||||||
`).Error; err != nil {
|
|
||||||
t.Fatalf("create legacy unique version index: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.DB().Where("id = ?", "2026_02_06_pricelist_index_fix").
|
|
||||||
Delete(&LocalSchemaMigration{}).Error; err != nil {
|
|
||||||
t.Fatalf("delete migration record: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := runLocalMigrations(local.DB()); err != nil {
|
|
||||||
t.Fatalf("rerun local migrations: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := local.SaveLocalPricelist(&LocalPricelist{
|
|
||||||
ServerID: 11,
|
|
||||||
Version: "2026-02-06-001",
|
|
||||||
Name: "v1-duplicate-version",
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
SyncedAt: time.Now(),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save second pricelist with duplicate version: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
if err := local.DB().Model(&LocalPricelist{}).Count(&count).Error; err != nil {
|
|
||||||
t.Fatalf("count pricelists: %v", err)
|
|
||||||
}
|
|
||||||
if count != 2 {
|
|
||||||
t.Fatalf("expected 2 pricelists, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,19 +4,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
|
||||||
uuidpkg "github.com/google/uuid"
|
uuidpkg "github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -145,23 +141,19 @@ func (l *LocalDB) GetDSN() (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := mysqlDriver.NewConfig()
|
// Add aggressive timeouts for offline-first architecture
|
||||||
cfg.User = settings.User
|
// timeout: connection establishment timeout (3s)
|
||||||
cfg.Passwd = settings.PasswordEncrypted // Contains decrypted password after GetSettings
|
// readTimeout: I/O read timeout (3s)
|
||||||
cfg.Net = "tcp"
|
// writeTimeout: I/O write timeout (3s)
|
||||||
cfg.Addr = net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s",
|
||||||
cfg.DBName = settings.Database
|
settings.User,
|
||||||
cfg.ParseTime = true
|
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
|
||||||
cfg.Loc = time.Local
|
settings.Host,
|
||||||
// Add aggressive timeouts for offline-first architecture.
|
settings.Port,
|
||||||
cfg.Timeout = 3 * time.Second
|
settings.Database,
|
||||||
cfg.ReadTimeout = 3 * time.Second
|
)
|
||||||
cfg.WriteTimeout = 3 * time.Second
|
|
||||||
cfg.Params = map[string]string{
|
|
||||||
"charset": "utf8mb4",
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg.FormatDSN(), nil
|
return dsn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB returns the underlying gorm.DB for advanced operations
|
// DB returns the underlying gorm.DB for advanced operations
|
||||||
@@ -523,16 +515,7 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
|||||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
var pricelist LocalPricelist
|
||||||
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&pricelist).Error; err != nil {
|
if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &pricelist, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
|
||||||
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
|
||||||
var pricelist LocalPricelist
|
|
||||||
if err := l.db.Where("source = ?", source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
@@ -547,24 +530,6 @@ func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, e
|
|||||||
return &pricelist, nil
|
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 = ? AND source = ?", version, "estimate").First(&pricelist).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &pricelist, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLocalPricelistBySourceAndVersion returns a local pricelist by source and version string.
|
|
||||||
func (l *LocalDB) GetLocalPricelistBySourceAndVersion(source, version string) (*LocalPricelist, error) {
|
|
||||||
var pricelist LocalPricelist
|
|
||||||
if err := l.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &pricelist, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLocalPricelistByID returns a local pricelist by its local ID
|
// GetLocalPricelistByID returns a local pricelist by its local ID
|
||||||
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
||||||
var pricelist LocalPricelist
|
var pricelist LocalPricelist
|
||||||
@@ -576,17 +541,7 @@ func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
|||||||
|
|
||||||
// SaveLocalPricelist saves a pricelist to local SQLite
|
// SaveLocalPricelist saves a pricelist to local SQLite
|
||||||
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
|
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
|
||||||
return l.db.Clauses(clause.OnConflict{
|
return l.db.Save(pricelist).Error
|
||||||
Columns: []clause.Column{{Name: "server_id"}},
|
|
||||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
|
||||||
"source": pricelist.Source,
|
|
||||||
"version": pricelist.Version,
|
|
||||||
"name": pricelist.Name,
|
|
||||||
"created_at": pricelist.CreatedAt,
|
|
||||||
"synced_at": pricelist.SyncedAt,
|
|
||||||
"is_used": pricelist.IsUsed,
|
|
||||||
}),
|
|
||||||
}).Create(pricelist).Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocalPricelists returns all local pricelists
|
// GetLocalPricelists returns all local pricelists
|
||||||
@@ -650,25 +605,6 @@ func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
|||||||
Update("is_used", isUsed).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
|
// DeleteLocalPricelist deletes a pricelist and its items
|
||||||
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
||||||
// Delete items first
|
// Delete items first
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -43,21 +42,6 @@ var localMigrations = []localMigration{
|
|||||||
name: "Create default projects and attach existing configurations",
|
name: "Create default projects and attach existing configurations",
|
||||||
run: backfillProjectsForConfigurations,
|
run: backfillProjectsForConfigurations,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "2026_02_06_pricelist_backfill",
|
|
||||||
name: "Attach existing configurations to latest local pricelist and recalc usage",
|
|
||||||
run: backfillConfigurationPricelists,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_06_pricelist_index_fix",
|
|
||||||
name: "Use unique server_id for local pricelists and allow duplicate versions",
|
|
||||||
run: fixLocalPricelistIndexes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2026_02_06_pricelist_source",
|
|
||||||
name: "Backfill source for local pricelists and create source indexes",
|
|
||||||
run: backfillLocalPricelistSource,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLocalMigrations(db *gorm.DB) error {
|
func runLocalMigrations(db *gorm.DB) error {
|
||||||
@@ -208,111 +192,9 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
|||||||
return &project, nil
|
return &project, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
|
||||||
var latest LocalPricelist
|
|
||||||
if err := tx.Where("source = ?", "estimate").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 {
|
func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
||||||
if candidate.IsZero() {
|
if candidate.IsZero() {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
|
||||||
type indexRow struct {
|
|
||||||
Name string `gorm:"column:name"`
|
|
||||||
Unique int `gorm:"column:unique"`
|
|
||||||
}
|
|
||||||
var indexes []indexRow
|
|
||||||
if err := tx.Raw("PRAGMA index_list('local_pricelists')").Scan(&indexes).Error; err != nil {
|
|
||||||
return fmt.Errorf("list local_pricelists indexes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, idx := range indexes {
|
|
||||||
if idx.Unique == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
type indexInfoRow struct {
|
|
||||||
Name string `gorm:"column:name"`
|
|
||||||
}
|
|
||||||
var info []indexInfoRow
|
|
||||||
if err := tx.Raw(fmt.Sprintf("PRAGMA index_info('%s')", strings.ReplaceAll(idx.Name, "'", "''"))).Scan(&info).Error; err != nil {
|
|
||||||
return fmt.Errorf("load index info for %s: %w", idx.Name, err)
|
|
||||||
}
|
|
||||||
if len(info) != 1 || info[0].Name != "version" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
quoted := strings.ReplaceAll(idx.Name, `"`, `""`)
|
|
||||||
if err := tx.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, quoted)).Error; err != nil {
|
|
||||||
return fmt.Errorf("drop unique version index %s: %w", idx.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_server_id
|
|
||||||
ON local_pricelists(server_id)
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("ensure unique index local_pricelists(server_id): %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_local_pricelists_version
|
|
||||||
ON local_pricelists(version)
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("ensure index local_pricelists(version): %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func backfillLocalPricelistSource(tx *gorm.DB) error {
|
|
||||||
if err := tx.Exec(`
|
|
||||||
UPDATE local_pricelists
|
|
||||||
SET source = 'estimate'
|
|
||||||
WHERE source IS NULL OR source = ''
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("backfill local_pricelists.source: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Exec(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_local_pricelists_source_created_at
|
|
||||||
ON local_pricelists(source, created_at DESC)
|
|
||||||
`).Error; err != nil {
|
|
||||||
return fmt.Errorf("ensure idx_local_pricelists_source_created_at: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ type LocalConfiguration struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
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"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -94,7 +93,6 @@ type LocalProject struct {
|
|||||||
ServerID *uint `json:"server_id,omitempty"`
|
ServerID *uint `json:"server_id,omitempty"`
|
||||||
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
TrackerURL string `json:"tracker_url"`
|
|
||||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -127,11 +125,10 @@ func (LocalConfigurationVersion) TableName() string {
|
|||||||
// LocalPricelist stores cached pricelists from server
|
// LocalPricelist stores cached pricelists from server
|
||||||
type LocalPricelist struct {
|
type LocalPricelist struct {
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
ServerID uint `gorm:"not null;uniqueIndex" json:"server_id"` // ID on MariaDB server
|
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
|
||||||
Source string `gorm:"not null;default:'estimate';index:idx_local_pricelists_source_created_at,priority:1" json:"source"`
|
Version string `gorm:"uniqueIndex;not null" json:"version"`
|
||||||
Version string `gorm:"not null;index" json:"version"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
SyncedAt time.Time `json:"synced_at"`
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
|||||||
"notes": localCfg.Notes,
|
"notes": localCfg.Notes,
|
||||||
"is_template": localCfg.IsTemplate,
|
"is_template": localCfg.IsTemplate,
|
||||||
"server_count": localCfg.ServerCount,
|
"server_count": localCfg.ServerCount,
|
||||||
"pricelist_id": localCfg.PricelistID,
|
|
||||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||||
"created_at": localCfg.CreatedAt,
|
"created_at": localCfg.CreatedAt,
|
||||||
"updated_at": localCfg.UpdatedAt,
|
"updated_at": localCfg.UpdatedAt,
|
||||||
@@ -51,7 +50,6 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `json:"is_template"`
|
IsTemplate bool `json:"is_template"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
PricelistID *uint `json:"pricelist_id"`
|
|
||||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||||
OriginalUserID uint `json:"original_user_id"`
|
OriginalUserID uint `json:"original_user_id"`
|
||||||
OriginalUsername string `json:"original_username"`
|
OriginalUsername string `json:"original_username"`
|
||||||
@@ -76,7 +74,6 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
Notes: snapshot.Notes,
|
Notes: snapshot.Notes,
|
||||||
IsTemplate: snapshot.IsTemplate,
|
IsTemplate: snapshot.IsTemplate,
|
||||||
ServerCount: snapshot.ServerCount,
|
ServerCount: snapshot.ServerCount,
|
||||||
PricelistID: snapshot.PricelistID,
|
|
||||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||||
OriginalUserID: snapshot.OriginalUserID,
|
OriginalUserID: snapshot.OriginalUserID,
|
||||||
OriginalUsername: snapshot.OriginalUsername,
|
OriginalUsername: snapshot.OriginalUsername,
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ type Configuration struct {
|
|||||||
Notes string `gorm:"type:text" json:"notes"`
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
|
||||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
|
||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
|
||||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
|
||||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|
||||||
|
|||||||
@@ -4,41 +4,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PricelistSource string
|
|
||||||
|
|
||||||
const (
|
|
||||||
PricelistSourceEstimate PricelistSource = "estimate"
|
|
||||||
PricelistSourceWarehouse PricelistSource = "warehouse"
|
|
||||||
PricelistSourceCompetitor PricelistSource = "competitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s PricelistSource) IsValid() bool {
|
|
||||||
switch s {
|
|
||||||
case PricelistSourceEstimate, PricelistSourceWarehouse, PricelistSourceCompetitor:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NormalizePricelistSource(source string) PricelistSource {
|
|
||||||
switch PricelistSource(source) {
|
|
||||||
case PricelistSourceWarehouse:
|
|
||||||
return PricelistSourceWarehouse
|
|
||||||
case PricelistSourceCompetitor:
|
|
||||||
return PricelistSourceCompetitor
|
|
||||||
default:
|
|
||||||
return PricelistSourceEstimate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pricelist represents a versioned snapshot of prices
|
// Pricelist represents a versioned snapshot of prices
|
||||||
type Pricelist struct {
|
type Pricelist struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Source string `gorm:"size:20;not null;default:'estimate';uniqueIndex:idx_qt_pricelists_source_version,priority:1;index:idx_qt_pricelists_source_created_at,priority:1" json:"source"`
|
Version string `gorm:"size:20;uniqueIndex;not null" json:"version"` // Format: YYYY-MM-DD-NNN
|
||||||
Version string `gorm:"size:20;not null;uniqueIndex:idx_qt_pricelists_source_version,priority:2" json:"version"` // Format: YYYY-MM-DD-NNN
|
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
|
||||||
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
|
CreatedAt time.Time `json:"created_at"`
|
||||||
CreatedAt time.Time `gorm:"index:idx_qt_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
|
||||||
CreatedBy string `gorm:"size:100" json:"created_by"`
|
CreatedBy string `gorm:"size:100" json:"created_by"`
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
UsageCount int `gorm:"default:0" json:"usage_count"`
|
UsageCount int `gorm:"default:0" json:"usage_count"`
|
||||||
@@ -76,7 +47,6 @@ func (PricelistItem) TableName() string {
|
|||||||
// PricelistSummary is used for list views
|
// PricelistSummary is used for list views
|
||||||
type PricelistSummary struct {
|
type PricelistSummary struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Source string `json:"source"`
|
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Notification string `json:"notification"`
|
Notification string `json:"notification"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ type Project struct {
|
|||||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||||
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
||||||
Name string `gorm:"size:200;not null" json:"name"`
|
Name string `gorm:"size:200;not null" json:"name"`
|
||||||
TrackerURL string `gorm:"size:500" json:"tracker_url"`
|
|
||||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -10,7 +9,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,30 +22,6 @@ func (SQLSchemaMigration) TableName() string {
|
|||||||
return "qt_schema_migrations"
|
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.
|
// RunSQLMigrations applies SQL files from migrationsDir once and records them in qt_schema_migrations.
|
||||||
// Local SQLite-only scripts are skipped automatically.
|
// Local SQLite-only scripts are skipped automatically.
|
||||||
func RunSQLMigrations(db *gorm.DB, migrationsDir string) error {
|
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)
|
return fmt.Errorf("migrate qt_schema_migrations table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := listSQLMigrationFiles(migrationsDir)
|
entries, err := os.ReadDir(migrationsDir)
|
||||||
if err != nil {
|
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 {
|
for _, filename := range files {
|
||||||
var count int64
|
var count int64
|
||||||
if err := db.Model(&SQLSchemaMigration{}).Where("filename = ?", filename).Count(&count).Error; err != nil {
|
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
|
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 {
|
func ensureSQLMigrationsTable(db *gorm.DB) error {
|
||||||
stmt := `
|
stmt := `
|
||||||
CREATE TABLE IF NOT EXISTS qt_schema_migrations (
|
CREATE TABLE IF NOT EXISTS qt_schema_migrations (
|
||||||
@@ -198,30 +157,3 @@ func splitSQLStatements(script string) []string {
|
|||||||
}
|
}
|
||||||
return stmts
|
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
|
return r.db.Save(component).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ComponentRepository) DB() *gorm.DB {
|
|
||||||
return r.db
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
||||||
return r.db.Create(component).Error
|
return r.db.Create(component).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,23 +21,13 @@ func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
|
|||||||
|
|
||||||
// List returns pricelists with pagination
|
// List returns pricelists with pagination
|
||||||
func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||||
return r.ListBySource("", offset, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListBySource returns pricelists filtered by source when provided.
|
|
||||||
func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
|
||||||
query := r.db.Model(&models.Pricelist{})
|
|
||||||
if source != "" {
|
|
||||||
query = query.Where("source = ?", source)
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := r.db.Model(&models.Pricelist{}).Count(&total).Error; err != nil {
|
||||||
return nil, 0, fmt.Errorf("counting pricelists: %w", err)
|
return nil, 0, fmt.Errorf("counting pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var pricelists []models.Pricelist
|
var pricelists []models.Pricelist
|
||||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
if err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||||
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,23 +36,13 @@ func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]
|
|||||||
|
|
||||||
// ListActive returns active pricelists with pagination.
|
// ListActive returns active pricelists with pagination.
|
||||||
func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||||
return r.ListActiveBySource("", offset, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListActiveBySource returns active pricelists filtered by source when provided.
|
|
||||||
func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
|
||||||
query := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true)
|
|
||||||
if source != "" {
|
|
||||||
query = query.Where("source = ?", source)
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil {
|
||||||
return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
|
return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var pricelists []models.Pricelist
|
var pricelists []models.Pricelist
|
||||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||||
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,17 +64,15 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
|
|||||||
for i, pl := range pricelists {
|
for i, pl := range pricelists {
|
||||||
var itemCount int64
|
var itemCount int64
|
||||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
|
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
|
||||||
usageCount, _ := r.CountUsage(pl.ID)
|
|
||||||
|
|
||||||
summaries[i] = models.PricelistSummary{
|
summaries[i] = models.PricelistSummary{
|
||||||
ID: pl.ID,
|
ID: pl.ID,
|
||||||
Source: pl.Source,
|
|
||||||
Version: pl.Version,
|
Version: pl.Version,
|
||||||
Notification: pl.Notification,
|
Notification: pl.Notification,
|
||||||
CreatedAt: pl.CreatedAt,
|
CreatedAt: pl.CreatedAt,
|
||||||
CreatedBy: pl.CreatedBy,
|
CreatedBy: pl.CreatedBy,
|
||||||
IsActive: pl.IsActive,
|
IsActive: pl.IsActive,
|
||||||
UsageCount: int(usageCount),
|
UsageCount: pl.UsageCount,
|
||||||
ExpiresAt: pl.ExpiresAt,
|
ExpiresAt: pl.ExpiresAt,
|
||||||
ItemCount: itemCount,
|
ItemCount: itemCount,
|
||||||
}
|
}
|
||||||
@@ -114,22 +92,14 @@ func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
|
|||||||
var itemCount int64
|
var itemCount int64
|
||||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
|
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
|
||||||
pricelist.ItemCount = int(itemCount)
|
pricelist.ItemCount = int(itemCount)
|
||||||
if usageCount, err := r.CountUsage(id); err == nil {
|
|
||||||
pricelist.UsageCount = int(usageCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByVersion returns a pricelist by version string
|
// GetByVersion returns a pricelist by version string
|
||||||
func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) {
|
func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) {
|
||||||
return r.GetBySourceAndVersion(string(models.PricelistSourceEstimate), version)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBySourceAndVersion returns a pricelist by source/version.
|
|
||||||
func (r *PricelistRepository) GetBySourceAndVersion(source, version string) (*models.Pricelist, error) {
|
|
||||||
var pricelist models.Pricelist
|
var pricelist models.Pricelist
|
||||||
if err := r.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
|
if err := r.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
|
||||||
return nil, fmt.Errorf("getting pricelist by version: %w", err)
|
return nil, fmt.Errorf("getting pricelist by version: %w", err)
|
||||||
}
|
}
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
@@ -137,13 +107,8 @@ func (r *PricelistRepository) GetBySourceAndVersion(source, version string) (*mo
|
|||||||
|
|
||||||
// GetLatestActive returns the most recent active pricelist
|
// GetLatestActive returns the most recent active pricelist
|
||||||
func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
||||||
return r.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLatestActiveBySource returns the most recent active pricelist by source.
|
|
||||||
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
|
||||||
var pricelist models.Pricelist
|
var pricelist models.Pricelist
|
||||||
if err := r.db.Where("is_active = ? AND source = ?", true, source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||||
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
||||||
}
|
}
|
||||||
return &pricelist, nil
|
return &pricelist, nil
|
||||||
@@ -167,13 +132,13 @@ func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
|
|||||||
|
|
||||||
// Delete deletes a pricelist if usage_count is 0
|
// Delete deletes a pricelist if usage_count is 0
|
||||||
func (r *PricelistRepository) Delete(id uint) error {
|
func (r *PricelistRepository) Delete(id uint) error {
|
||||||
usageCount, err := r.CountUsage(id)
|
pricelist, err := r.GetByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if usageCount > 0 {
|
if pricelist.UsageCount > 0 {
|
||||||
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount)
|
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete items first
|
// Delete items first
|
||||||
@@ -243,33 +208,14 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
|||||||
return items, total, nil
|
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
|
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
|
||||||
func (r *PricelistRepository) GenerateVersion() (string, error) {
|
func (r *PricelistRepository) GenerateVersion() (string, error) {
|
||||||
return r.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source.
|
|
||||||
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
|
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
var last models.Pricelist
|
var last models.Pricelist
|
||||||
err := r.db.Model(&models.Pricelist{}).
|
err := r.db.Model(&models.Pricelist{}).
|
||||||
Select("version").
|
Select("version").
|
||||||
Where("source = ? AND version LIKE ?", source, today+"-%").
|
Where("version LIKE ?", today+"-%").
|
||||||
Order("version DESC").
|
Order("version DESC").
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Take(&last).Error
|
Take(&last).Error
|
||||||
@@ -293,19 +239,6 @@ func (r *PricelistRepository) GenerateVersionBySource(source string) (string, er
|
|||||||
return fmt.Sprintf("%s-%03d", today, n+1), nil
|
return fmt.Sprintf("%s-%03d", today, n+1), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPriceForLotBySource returns item price for a lot from latest active pricelist of source.
|
|
||||||
func (r *PricelistRepository) GetPriceForLotBySource(source, lotName string) (float64, uint, error) {
|
|
||||||
latest, err := r.GetLatestActiveBySource(source)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
price, err := r.GetPriceForLot(latest.ID, lotName)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
return price, latest.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanWrite checks if the current database user has INSERT permission on qt_pricelists
|
// CanWrite checks if the current database user has INSERT permission on qt_pricelists
|
||||||
func (r *PricelistRepository) CanWrite() bool {
|
func (r *PricelistRepository) CanWrite() bool {
|
||||||
canWrite, _ := r.CanWriteDebug()
|
canWrite, _ := r.CanWriteDebug()
|
||||||
@@ -362,15 +295,6 @@ func (r *PricelistRepository) DecrementUsageCount(id uint) error {
|
|||||||
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).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
|
// GetExpiredUnused returns pricelists that are expired and unused
|
||||||
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
|
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
|
||||||
var pricelists []models.Pricelist
|
var pricelists []models.Pricelist
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import (
|
|||||||
func TestGenerateVersion_FirstOfDay(t *testing.T) {
|
func TestGenerateVersion_FirstOfDay(t *testing.T) {
|
||||||
repo := newTestPricelistRepository(t)
|
repo := newTestPricelistRepository(t)
|
||||||
|
|
||||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
version, err := repo.GenerateVersion()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
t.Fatalf("GenerateVersion returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
@@ -30,8 +30,8 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
|||||||
today := time.Now().Format("2006-01-02")
|
today := time.Now().Format("2006-01-02")
|
||||||
|
|
||||||
seed := []models.Pricelist{
|
seed := []models.Pricelist{
|
||||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
|
{Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
|
||||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true},
|
{Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true},
|
||||||
}
|
}
|
||||||
for _, pl := range seed {
|
for _, pl := range seed {
|
||||||
if err := repo.Create(&pl); err != nil {
|
if err := repo.Create(&pl); err != nil {
|
||||||
@@ -39,9 +39,9 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
version, err := repo.GenerateVersion()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
t.Fatalf("GenerateVersion returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := fmt.Sprintf("%s-004", today)
|
want := fmt.Sprintf("%s-004", today)
|
||||||
@@ -50,31 +50,6 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateVersion_IsolatedBySource(t *testing.T) {
|
|
||||||
repo := newTestPricelistRepository(t)
|
|
||||||
today := time.Now().Format("2006-01-02")
|
|
||||||
|
|
||||||
seed := []models.Pricelist{
|
|
||||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-009", today), CreatedBy: "test", IsActive: true},
|
|
||||||
{Source: string(models.PricelistSourceWarehouse), Version: fmt.Sprintf("%s-002", today), CreatedBy: "test", IsActive: true},
|
|
||||||
}
|
|
||||||
for _, pl := range seed {
|
|
||||||
if err := repo.Create(&pl); err != nil {
|
|
||||||
t.Fatalf("seed insert failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceWarehouse))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := fmt.Sprintf("%s-003", today)
|
|
||||||
if version != want {
|
|
||||||
t.Fatalf("expected %s, got %s", want, version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectRepository struct {
|
type ProjectRepository struct {
|
||||||
@@ -22,30 +21,6 @@ func (r *ProjectRepository) Update(project *models.Project) error {
|
|||||||
return r.db.Save(project).Error
|
return r.db.Save(project).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
|
|
||||||
if err := r.db.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "uuid"}},
|
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
|
||||||
"owner_username",
|
|
||||||
"name",
|
|
||||||
"tracker_url",
|
|
||||||
"is_active",
|
|
||||||
"is_system",
|
|
||||||
"updated_at",
|
|
||||||
}),
|
|
||||||
}).Create(project).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure caller always gets canonical server ID.
|
|
||||||
var persisted models.Project
|
|
||||||
if err := r.db.Where("uuid = ?", project.UUID).First(&persisted).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
project.ID = persisted.ID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) {
|
func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) {
|
||||||
var project models.Project
|
var project models.Project
|
||||||
if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
|
if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ type ConfigurationService struct {
|
|||||||
configRepo *repository.ConfigurationRepository
|
configRepo *repository.ConfigurationRepository
|
||||||
projectRepo *repository.ProjectRepository
|
projectRepo *repository.ProjectRepository
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
pricelistRepo *repository.PricelistRepository
|
|
||||||
quoteService *QuoteService
|
quoteService *QuoteService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,14 +31,12 @@ func NewConfigurationService(
|
|||||||
configRepo *repository.ConfigurationRepository,
|
configRepo *repository.ConfigurationRepository,
|
||||||
projectRepo *repository.ProjectRepository,
|
projectRepo *repository.ProjectRepository,
|
||||||
componentRepo *repository.ComponentRepository,
|
componentRepo *repository.ComponentRepository,
|
||||||
pricelistRepo *repository.PricelistRepository,
|
|
||||||
quoteService *QuoteService,
|
quoteService *QuoteService,
|
||||||
) *ConfigurationService {
|
) *ConfigurationService {
|
||||||
return &ConfigurationService{
|
return &ConfigurationService{
|
||||||
configRepo: configRepo,
|
configRepo: configRepo,
|
||||||
projectRepo: projectRepo,
|
projectRepo: projectRepo,
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
pricelistRepo: pricelistRepo,
|
|
||||||
quoteService: quoteService,
|
quoteService: quoteService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +49,6 @@ type CreateConfigRequest struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `json:"is_template"`
|
IsTemplate bool `json:"is_template"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
|
|
||||||
@@ -83,7 +75,6 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
|||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
IsTemplate: req.IsTemplate,
|
IsTemplate: req.IsTemplate,
|
||||||
ServerCount: req.ServerCount,
|
ServerCount: req.ServerCount,
|
||||||
PricelistID: pricelistID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(config); err != nil {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
|
|
||||||
@@ -144,7 +131,6 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
|||||||
config.Notes = req.Notes
|
config.Notes = req.Notes
|
||||||
config.IsTemplate = req.IsTemplate
|
config.IsTemplate = req.IsTemplate
|
||||||
config.ServerCount = req.ServerCount
|
config.ServerCount = req.ServerCount
|
||||||
config.PricelistID = pricelistID
|
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -221,7 +207,6 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
|
|||||||
Notes: original.Notes,
|
Notes: original.Notes,
|
||||||
IsTemplate: false, // Clone is never a template
|
IsTemplate: false, // Clone is never a template
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
PricelistID: original.PricelistID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(clone); err != nil {
|
if err := s.configRepo.Create(clone); err != nil {
|
||||||
@@ -276,10 +261,6 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
if req.ServerCount > 1 {
|
if req.ServerCount > 1 {
|
||||||
@@ -294,7 +275,6 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
|
|||||||
config.Notes = req.Notes
|
config.Notes = req.Notes
|
||||||
config.IsTemplate = req.IsTemplate
|
config.IsTemplate = req.IsTemplate
|
||||||
config.ServerCount = req.ServerCount
|
config.ServerCount = req.ServerCount
|
||||||
config.PricelistID = pricelistID
|
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -361,7 +341,6 @@ func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName s
|
|||||||
Notes: original.Notes,
|
Notes: original.Notes,
|
||||||
IsTemplate: false,
|
IsTemplate: false,
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
PricelistID: original.PricelistID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(clone); err != nil {
|
if err := s.configRepo.Create(clone); err != nil {
|
||||||
@@ -391,23 +370,6 @@ func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectU
|
|||||||
return &project.UUID, nil
|
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
|
// RefreshPricesNoAuth refreshes prices without ownership check
|
||||||
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
config, err := s.configRepo.GetByUUID(uuid)
|
config, err := s.configRepo.GetByUUID(uuid)
|
||||||
@@ -415,30 +377,8 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
|
|||||||
return nil, ErrConfigNotFound
|
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))
|
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||||
for i, item := range 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)
|
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||||
if err != nil || metadata.CurrentPrice == nil {
|
if err != nil || metadata.CurrentPrice == nil {
|
||||||
updatedItems[i] = item
|
updatedItems[i] = item
|
||||||
@@ -459,9 +399,6 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.TotalPrice = &total
|
config.TotalPrice = &total
|
||||||
if latestPricelistID != nil {
|
|
||||||
config.PricelistID = latestPricelistID
|
|
||||||
}
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
config.PriceUpdatedAt = &now
|
config.PriceUpdatedAt = &now
|
||||||
|
|
||||||
@@ -495,32 +432,10 @@ func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string)
|
|||||||
return nil, ErrConfigForbidden
|
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
|
// Update prices for all items
|
||||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||||
for i, item := range 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
|
// Get current component price
|
||||||
if s.componentRepo == nil {
|
|
||||||
updatedItems[i] = item
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||||
if err != nil || metadata.CurrentPrice == nil {
|
if err != nil || metadata.CurrentPrice == nil {
|
||||||
// Keep original item if component not found or no price available
|
// 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
|
config.TotalPrice = &total
|
||||||
if latestPricelistID != nil {
|
|
||||||
config.PricelistID = latestPricelistID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set price update timestamp
|
// Set price update timestamp
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -59,10 +59,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total := req.Items.Total()
|
total := req.Items.Total()
|
||||||
if req.ServerCount > 1 {
|
if req.ServerCount > 1 {
|
||||||
@@ -80,7 +76,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
IsTemplate: req.IsTemplate,
|
IsTemplate: req.IsTemplate,
|
||||||
ServerCount: req.ServerCount,
|
ServerCount: req.ServerCount,
|
||||||
PricelistID: pricelistID,
|
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,14 +124,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
return nil, ErrConfigForbidden
|
return nil, ErrConfigForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
projectUUID := localCfg.ProjectUUID
|
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||||
if req.ProjectUUID != nil {
|
|
||||||
projectUUID, err = s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -162,7 +150,6 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
localCfg.Notes = req.Notes
|
localCfg.Notes = req.Notes
|
||||||
localCfg.IsTemplate = req.IsTemplate
|
localCfg.IsTemplate = req.IsTemplate
|
||||||
localCfg.ServerCount = req.ServerCount
|
localCfg.ServerCount = req.ServerCount
|
||||||
localCfg.PricelistID = pricelistID
|
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
@@ -267,7 +254,6 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
|||||||
Notes: original.Notes,
|
Notes: original.Notes,
|
||||||
IsTemplate: false,
|
IsTemplate: false,
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
PricelistID: original.PricelistID,
|
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,28 +324,10 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
return nil, ErrConfigForbidden
|
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
|
// Update prices for all items
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
// Get current component price from local cache
|
||||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
|
||||||
if err == nil && price > 0 {
|
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: price,
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to current component price from local cache
|
|
||||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||||
if err != nil || component.CurrentPrice == nil {
|
if err != nil || component.CurrentPrice == nil {
|
||||||
// Keep original item if component not found or no price available
|
// Keep original item if component not found or no price available
|
||||||
@@ -385,9 +353,6 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCfg.TotalPrice = &total
|
localCfg.TotalPrice = &total
|
||||||
if latestErr == nil && latestPricelist != nil {
|
|
||||||
localCfg.PricelistID = &latestPricelist.ServerID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set price update timestamp and mark for sync
|
// Set price update timestamp and mark for sync
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -421,14 +386,7 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
projectUUID := localCfg.ProjectUUID
|
projectUUID, err := s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||||
if req.ProjectUUID != nil {
|
|
||||||
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -453,7 +411,6 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
localCfg.Notes = req.Notes
|
localCfg.Notes = req.Notes
|
||||||
localCfg.IsTemplate = req.IsTemplate
|
localCfg.IsTemplate = req.IsTemplate
|
||||||
localCfg.ServerCount = req.ServerCount
|
localCfg.ServerCount = req.ServerCount
|
||||||
localCfg.PricelistID = pricelistID
|
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
|
|
||||||
@@ -545,7 +502,6 @@ func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newN
|
|||||||
Notes: original.Notes,
|
Notes: original.Notes,
|
||||||
IsTemplate: false,
|
IsTemplate: false,
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
PricelistID: original.PricelistID,
|
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,27 +640,10 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.isOnline() {
|
|
||||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
|
||||||
}
|
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
|
||||||
|
|
||||||
// Update prices for all items
|
// Update prices for all items
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
// Get current component price from local cache
|
||||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
|
||||||
if err == nil && price > 0 {
|
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: price,
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to current component price from local cache
|
|
||||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||||
if err != nil || component.CurrentPrice == nil {
|
if err != nil || component.CurrentPrice == nil {
|
||||||
// Keep original item if component not found or no price available
|
// Keep original item if component not found or no price available
|
||||||
@@ -730,9 +669,6 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCfg.TotalPrice = &total
|
localCfg.TotalPrice = &total
|
||||||
if latestErr == nil && latestPricelist != nil {
|
|
||||||
localCfg.PricelistID = &latestPricelist.ServerID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set price update timestamp and mark for sync
|
// Set price update timestamp and mark for sync
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -879,9 +815,6 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
|
|||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||||
return fmt.Errorf("enqueue create pending change: %w", err)
|
return fmt.Errorf("enqueue create pending change: %w", err)
|
||||||
}
|
}
|
||||||
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
|
|
||||||
return fmt.Errorf("recalculate local pricelist usage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -921,9 +854,6 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
||||||
return fmt.Errorf("enqueue %s pending change: %w", operation, err)
|
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
|
return nil
|
||||||
})
|
})
|
||||||
@@ -1028,7 +958,6 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
|||||||
current.Notes = rollbackData.Notes
|
current.Notes = rollbackData.Notes
|
||||||
current.IsTemplate = rollbackData.IsTemplate
|
current.IsTemplate = rollbackData.IsTemplate
|
||||||
current.ServerCount = rollbackData.ServerCount
|
current.ServerCount = rollbackData.ServerCount
|
||||||
current.PricelistID = rollbackData.PricelistID
|
|
||||||
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
||||||
current.UpdatedAt = time.Now()
|
current.UpdatedAt = time.Now()
|
||||||
current.SyncStatus = "pending"
|
current.SyncStatus = "pending"
|
||||||
@@ -1086,9 +1015,6 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
|||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, ¤t, "rollback", version, userID); err != nil {
|
if err := s.enqueueConfigurationPendingChangeTx(tx, ¤t, "rollback", version, userID); err != nil {
|
||||||
return fmt.Errorf("enqueue rollback pending change: %w", err)
|
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
|
return nil
|
||||||
})
|
})
|
||||||
@@ -1112,7 +1038,6 @@ func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx(
|
|||||||
IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation),
|
IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation),
|
||||||
ConfigurationUUID: localCfg.UUID,
|
ConfigurationUUID: localCfg.UUID,
|
||||||
ProjectUUID: localCfg.ProjectUUID,
|
ProjectUUID: localCfg.ProjectUUID,
|
||||||
PricelistID: localCfg.PricelistID,
|
|
||||||
Operation: operation,
|
Operation: operation,
|
||||||
CurrentVersionID: version.ID,
|
CurrentVersionID: version.ID,
|
||||||
CurrentVersionNo: version.VersionNo,
|
CurrentVersionNo: version.VersionNo,
|
||||||
@@ -1146,21 +1071,6 @@ func (s *LocalConfigurationService) decodeConfigurationSnapshot(data string) (*l
|
|||||||
return localdb.DecodeConfigurationSnapshot(data)
|
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 {
|
func stringPtrOrNil(value string) *string {
|
||||||
trimmed := strings.TrimSpace(value)
|
trimmed := strings.TrimSpace(value)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -1206,25 +1116,3 @@ func (s *LocalConfigurationService) resolveProjectUUID(ownerUsername string, pro
|
|||||||
|
|
||||||
return &project.UUID, nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -185,48 +185,6 @@ WHERE configuration_uuid = ?`, created.UUID).Scan(&c).Error; err != nil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
|
||||||
service, local := newLocalConfigServiceForTest(t)
|
|
||||||
|
|
||||||
project := &localdb.LocalProject{
|
|
||||||
UUID: "project-keep",
|
|
||||||
OwnerUsername: "tester",
|
|
||||||
Name: "Keep Project",
|
|
||||||
IsActive: true,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
SyncStatus: "synced",
|
|
||||||
}
|
|
||||||
if err := local.SaveProject(project); err != nil {
|
|
||||||
t.Fatalf("save project: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := service.Create("tester", &CreateConfigRequest{
|
|
||||||
Name: "cfg",
|
|
||||||
ProjectUUID: &project.UUID,
|
|
||||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
|
||||||
ServerCount: 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create config: %v", err)
|
|
||||||
}
|
|
||||||
if created.ProjectUUID == nil || *created.ProjectUUID != project.UUID {
|
|
||||||
t.Fatalf("expected created config project_uuid=%s", project.UUID)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
|
||||||
Name: "cfg-updated",
|
|
||||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
|
||||||
ServerCount: 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("update config without project_uuid: %v", err)
|
|
||||||
}
|
|
||||||
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
|
|
||||||
t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -9,106 +9,39 @@ import (
|
|||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo *repository.PricelistRepository
|
repo *repository.PricelistRepository
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
pricingSvc *pricing.Service
|
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateProgress struct {
|
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service {
|
||||||
Current int
|
|
||||||
Total int
|
|
||||||
Status string
|
|
||||||
Message string
|
|
||||||
Updated int
|
|
||||||
Errors int
|
|
||||||
LotName string
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateItemInput struct {
|
|
||||||
LotName string
|
|
||||||
Price float64
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
|
|
||||||
return &Service{
|
return &Service{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
pricingSvc: pricingSvc,
|
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
||||||
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
||||||
return s.CreateFromCurrentPricesForSource(createdBy, string(models.PricelistSourceEstimate))
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateFromCurrentPricesForSource creates a new pricelist snapshot for one source.
|
|
||||||
func (s *Service) CreateFromCurrentPricesForSource(createdBy, source string) (*models.Pricelist, error) {
|
|
||||||
return s.CreateForSourceWithProgress(createdBy, source, nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress.
|
|
||||||
func (s *Service) CreateFromCurrentPricesWithProgress(createdBy, source string, onProgress func(CreateProgress)) (*models.Pricelist, error) {
|
|
||||||
return s.CreateForSourceWithProgress(createdBy, source, nil, onProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateForSourceWithProgress creates a source pricelist from current estimate snapshot or explicit item list.
|
|
||||||
func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceItems []CreateItemInput, onProgress func(CreateProgress)) (*models.Pricelist, error) {
|
|
||||||
if s.repo == nil || s.db == nil {
|
if s.repo == nil || s.db == nil {
|
||||||
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
||||||
}
|
}
|
||||||
source = string(models.NormalizePricelistSource(source))
|
|
||||||
|
|
||||||
report := func(p CreateProgress) {
|
|
||||||
if onProgress != nil {
|
|
||||||
onProgress(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
|
|
||||||
|
|
||||||
updated, errs := 0, 0
|
|
||||||
if source == string(models.PricelistSourceEstimate) && 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
|
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
|
||||||
const maxCreateAttempts = 5
|
const maxCreateAttempts = 5
|
||||||
var pricelist *models.Pricelist
|
var pricelist *models.Pricelist
|
||||||
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
|
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
|
||||||
version, err := s.repo.GenerateVersionBySource(source)
|
version, err := s.repo.GenerateVersion()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generating version: %w", err)
|
return nil, fmt.Errorf("generating version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pricelist = &models.Pricelist{
|
pricelist = &models.Pricelist{
|
||||||
Source: source,
|
|
||||||
Version: version,
|
Version: version,
|
||||||
CreatedBy: createdBy,
|
CreatedBy: createdBy,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
@@ -130,43 +63,28 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]models.PricelistItem, 0)
|
// Get all components with prices from qt_lot_metadata
|
||||||
if len(sourceItems) > 0 {
|
var metadata []models.LotMetadata
|
||||||
items = make([]models.PricelistItem, 0, len(sourceItems))
|
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
|
||||||
for _, srcItem := range sourceItems {
|
return nil, fmt.Errorf("getting lot metadata: %w", err)
|
||||||
if strings.TrimSpace(srcItem.LotName) == "" || srcItem.Price <= 0 {
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, models.PricelistItem{
|
|
||||||
PricelistID: pricelist.ID,
|
|
||||||
LotName: strings.TrimSpace(srcItem.LotName),
|
|
||||||
Price: srcItem.Price,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Default snapshot source for estimate and backward compatibility.
|
|
||||||
var metadata []models.LotMetadata
|
|
||||||
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
|
|
||||||
return nil, fmt.Errorf("getting lot metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create pricelist items with all price settings
|
// Create pricelist items with all price settings
|
||||||
items = make([]models.PricelistItem, 0, len(metadata))
|
items := make([]models.PricelistItem, 0, len(metadata))
|
||||||
for _, m := range metadata {
|
for _, m := range metadata {
|
||||||
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
|
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
|
||||||
items = append(items, models.PricelistItem{
|
|
||||||
PricelistID: pricelist.ID,
|
|
||||||
LotName: m.LotName,
|
|
||||||
Price: *m.CurrentPrice,
|
|
||||||
PriceMethod: string(m.PriceMethod),
|
|
||||||
PricePeriodDays: m.PricePeriodDays,
|
|
||||||
PriceCoefficient: m.PriceCoefficient,
|
|
||||||
ManualPrice: m.ManualPrice,
|
|
||||||
MetaPrices: m.MetaPrices,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
items = append(items, models.PricelistItem{
|
||||||
|
PricelistID: pricelist.ID,
|
||||||
|
LotName: m.LotName,
|
||||||
|
Price: *m.CurrentPrice,
|
||||||
|
PriceMethod: string(m.PriceMethod),
|
||||||
|
PricePeriodDays: m.PricePeriodDays,
|
||||||
|
PriceCoefficient: m.PriceCoefficient,
|
||||||
|
ManualPrice: m.ManualPrice,
|
||||||
|
MetaPrices: m.MetaPrices,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.repo.CreateItems(items); err != nil {
|
if err := s.repo.CreateItems(items); err != nil {
|
||||||
@@ -183,7 +101,6 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt
|
|||||||
"items", len(items),
|
"items", len(items),
|
||||||
"created_by", createdBy,
|
"created_by", createdBy,
|
||||||
)
|
)
|
||||||
report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs})
|
|
||||||
|
|
||||||
return pricelist, nil
|
return pricelist, nil
|
||||||
}
|
}
|
||||||
@@ -193,17 +110,11 @@ func isVersionConflictError(err error) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
msg := strings.ToLower(err.Error())
|
msg := strings.ToLower(err.Error())
|
||||||
return strings.Contains(msg, "duplicate entry") &&
|
return strings.Contains(msg, "duplicate entry") && strings.Contains(msg, "idx_qt_pricelists_version")
|
||||||
(strings.Contains(msg, "idx_qt_pricelists_source_version") || strings.Contains(msg, "idx_qt_pricelists_version"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns pricelists with pagination
|
// List returns pricelists with pagination
|
||||||
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||||
return s.ListBySource(page, perPage, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListBySource returns pricelists with optional source filter.
|
|
||||||
func (s *Service) ListBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
|
|
||||||
// If no database connection (offline mode), return empty list
|
// If no database connection (offline mode), return empty list
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return []models.PricelistSummary{}, 0, nil
|
return []models.PricelistSummary{}, 0, nil
|
||||||
@@ -216,27 +127,7 @@ func (s *Service) ListBySource(page, perPage int, source string) ([]models.Price
|
|||||||
perPage = 20
|
perPage = 20
|
||||||
}
|
}
|
||||||
offset := (page - 1) * perPage
|
offset := (page - 1) * perPage
|
||||||
return s.repo.ListBySource(source, offset, perPage)
|
return s.repo.List(offset, perPage)
|
||||||
}
|
|
||||||
|
|
||||||
// ListActive returns active pricelists with pagination.
|
|
||||||
func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
|
||||||
return s.ListActiveBySource(page, perPage, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListActiveBySource returns active pricelists with optional source filter.
|
|
||||||
func (s *Service) ListActiveBySource(page, perPage int, source string) ([]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.ListActiveBySource(source, offset, perPage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID returns a pricelist by ID
|
// GetByID returns a pricelist by ID
|
||||||
@@ -270,22 +161,6 @@ func (s *Service) Delete(id uint) error {
|
|||||||
return s.repo.Delete(id)
|
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
|
// CanWrite returns true if the user can create pricelists
|
||||||
func (s *Service) CanWrite() bool {
|
func (s *Service) CanWrite() bool {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
@@ -304,15 +179,10 @@ func (s *Service) CanWriteDebug() (bool, string) {
|
|||||||
|
|
||||||
// GetLatestActive returns the most recent active pricelist
|
// GetLatestActive returns the most recent active pricelist
|
||||||
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
||||||
return s.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLatestActiveBySource returns the latest active pricelist for a source.
|
|
||||||
func (s *Service) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
||||||
}
|
}
|
||||||
return s.repo.GetLatestActiveBySource(source)
|
return s.repo.GetLatestActive()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupExpired deletes expired and unused pricelists
|
// CleanupExpired deletes expired and unused pricelists
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
package pricing
|
package pricing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
priceRepo *repository.PriceRepository
|
priceRepo *repository.PriceRepository
|
||||||
config config.PricingConfig
|
config config.PricingConfig
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type RecalculateProgress struct {
|
|
||||||
Current int
|
|
||||||
Total int
|
|
||||||
LotName string
|
|
||||||
Updated int
|
|
||||||
Errors int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
@@ -30,16 +19,10 @@ func NewService(
|
|||||||
priceRepo *repository.PriceRepository,
|
priceRepo *repository.PriceRepository,
|
||||||
cfg config.PricingConfig,
|
cfg config.PricingConfig,
|
||||||
) *Service {
|
) *Service {
|
||||||
var db *gorm.DB
|
|
||||||
if componentRepo != nil {
|
|
||||||
db = componentRepo.DB()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
priceRepo: priceRepo,
|
priceRepo: priceRepo,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
db: db,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,183 +179,27 @@ type PriceStats struct {
|
|||||||
|
|
||||||
// RecalculateAllPrices recalculates prices for all components
|
// RecalculateAllPrices recalculates prices for all components
|
||||||
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
|
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.
|
for {
|
||||||
func (s *Service) RecalculateAllPricesWithProgress(onProgress func(RecalculateProgress)) (updated int, errors int) {
|
components, _, err := s.componentRepo.List(filter, offset, limit)
|
||||||
if s.db == nil {
|
if err != nil || len(components) == 0 {
|
||||||
return 0, 0
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logic mirrors "Обновить цены" in admin pricing.
|
for _, comp := range components {
|
||||||
var components []models.LotMetadata
|
if err := s.UpdateComponentPrice(comp.LotName); err != nil {
|
||||||
if err := s.db.Find(&components).Error; err != nil {
|
errors++
|
||||||
return 0, len(components)
|
} else {
|
||||||
}
|
updated++
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
offset += limit
|
||||||
manual++
|
|
||||||
reportProgress()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
method := comp.PriceMethod
|
|
||||||
if method == "" {
|
|
||||||
method = models.PriceMethodMedian
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourceLots []string
|
|
||||||
if comp.MetaPrices != "" {
|
|
||||||
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
|
|
||||||
} else {
|
|
||||||
sourceLots = []string{comp.LotName}
|
|
||||||
}
|
|
||||||
if len(sourceLots) == 0 {
|
|
||||||
skipped++
|
|
||||||
reportProgress()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if comp.PriceUpdatedAt != nil {
|
|
||||||
hasNewData := false
|
|
||||||
for _, lot := range sourceLots {
|
|
||||||
if latestDate, ok := lotLatestDate[lot]; ok && latestDate.After(*comp.PriceUpdatedAt) {
|
|
||||||
hasNewData = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasNewData {
|
|
||||||
unchanged++
|
|
||||||
reportProgress()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var prices []float64
|
|
||||||
if comp.PricePeriodDays > 0 {
|
|
||||||
_ = s.db.Raw(
|
|
||||||
`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
|
||||||
sourceLots, comp.PricePeriodDays,
|
|
||||||
).Pluck("price", &prices).Error
|
|
||||||
} else {
|
|
||||||
_ = s.db.Raw(
|
|
||||||
`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
|
|
||||||
sourceLots,
|
|
||||||
).Pluck("price", &prices).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(prices) == 0 && comp.PricePeriodDays > 0 {
|
|
||||||
_ = s.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices).Error
|
|
||||||
}
|
|
||||||
if len(prices) == 0 {
|
|
||||||
skipped++
|
|
||||||
reportProgress()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var basePrice float64
|
|
||||||
switch method {
|
|
||||||
case models.PriceMethodAverage:
|
|
||||||
basePrice = CalculateAverage(prices)
|
|
||||||
default:
|
|
||||||
basePrice = CalculateMedian(prices)
|
|
||||||
}
|
|
||||||
if basePrice <= 0 {
|
|
||||||
skipped++
|
|
||||||
reportProgress()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
finalPrice := basePrice
|
|
||||||
if comp.PriceCoefficient != 0 {
|
|
||||||
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.Model(&models.LotMetadata{}).
|
|
||||||
Where("lot_name = ?", comp.LotName).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"current_price": finalPrice,
|
|
||||||
"price_updated_at": now,
|
|
||||||
}).Error; err != nil {
|
|
||||||
errors++
|
|
||||||
} else {
|
|
||||||
updated++
|
|
||||||
}
|
|
||||||
|
|
||||||
reportProgress()
|
|
||||||
}
|
|
||||||
|
|
||||||
if onProgress != nil && total == 0 {
|
|
||||||
onProgress(RecalculateProgress{
|
|
||||||
Current: 0,
|
|
||||||
Total: 0,
|
|
||||||
LotName: "",
|
|
||||||
Updated: updated,
|
|
||||||
Errors: errors,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated, errors
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,13 +28,11 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateProjectRequest struct {
|
type CreateProjectRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
TrackerURL string `json:"tracker_url"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProjectRequest struct {
|
type UpdateProjectRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
TrackerURL *string `json:"tracker_url,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectConfigurationsResult struct {
|
type ProjectConfigurationsResult struct {
|
||||||
@@ -55,7 +52,6 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
|||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
Name: name,
|
Name: name,
|
||||||
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
|
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: false,
|
IsSystem: false,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -86,11 +82,6 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
}
|
}
|
||||||
|
|
||||||
localProject.Name = name
|
localProject.Name = name
|
||||||
if req.TrackerURL != nil {
|
|
||||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
|
|
||||||
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
|
||||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
|
|
||||||
}
|
|
||||||
localProject.UpdatedAt = time.Now()
|
localProject.UpdatedAt = time.Now()
|
||||||
localProject.SyncStatus = "pending"
|
localProject.SyncStatus = "pending"
|
||||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||||
@@ -269,20 +260,6 @@ func (s *ProjectService) ResolveProjectUUID(ownerUsername string, projectUUID *s
|
|||||||
return &resolved, nil
|
return &resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeProjectTrackerURL(projectCode, trackerURL string) string {
|
|
||||||
trimmedURL := strings.TrimSpace(trackerURL)
|
|
||||||
if trimmedURL != "" {
|
|
||||||
return trimmedURL
|
|
||||||
}
|
|
||||||
|
|
||||||
trimmedCode := strings.TrimSpace(projectCode)
|
|
||||||
if trimmedCode == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return "https://tracker.yandex.ru/" + url.PathEscape(trimmedCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProjectService) enqueueProjectPendingChange(project *localdb.LocalProject, operation string) error {
|
func (s *ProjectService) enqueueProjectPendingChange(project *localdb.LocalProject, operation string) error {
|
||||||
return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation)
|
return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package services
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||||
@@ -18,23 +17,17 @@ var (
|
|||||||
type QuoteService struct {
|
type QuoteService struct {
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
statsRepo *repository.StatsRepository
|
statsRepo *repository.StatsRepository
|
||||||
pricelistRepo *repository.PricelistRepository
|
|
||||||
localDB *localdb.LocalDB
|
|
||||||
pricingService *pricing.Service
|
pricingService *pricing.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQuoteService(
|
func NewQuoteService(
|
||||||
componentRepo *repository.ComponentRepository,
|
componentRepo *repository.ComponentRepository,
|
||||||
statsRepo *repository.StatsRepository,
|
statsRepo *repository.StatsRepository,
|
||||||
pricelistRepo *repository.PricelistRepository,
|
|
||||||
localDB *localdb.LocalDB,
|
|
||||||
pricingService *pricing.Service,
|
pricingService *pricing.Service,
|
||||||
) *QuoteService {
|
) *QuoteService {
|
||||||
return &QuoteService{
|
return &QuoteService{
|
||||||
componentRepo: componentRepo,
|
componentRepo: componentRepo,
|
||||||
statsRepo: statsRepo,
|
statsRepo: statsRepo,
|
||||||
pricelistRepo: pricelistRepo,
|
|
||||||
localDB: localDB,
|
|
||||||
pricingService: pricingService,
|
pricingService: pricingService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,34 +57,6 @@ type QuoteRequest struct {
|
|||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PriceLevelsRequest struct {
|
|
||||||
Items []struct {
|
|
||||||
LotName string `json:"lot_name"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
} `json:"items"`
|
|
||||||
PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PriceLevelsItem struct {
|
|
||||||
LotName string `json:"lot_name"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
EstimatePrice *float64 `json:"estimate_price"`
|
|
||||||
WarehousePrice *float64 `json:"warehouse_price"`
|
|
||||||
CompetitorPrice *float64 `json:"competitor_price"`
|
|
||||||
DeltaWhEstimateAbs *float64 `json:"delta_wh_estimate_abs"`
|
|
||||||
DeltaWhEstimatePct *float64 `json:"delta_wh_estimate_pct"`
|
|
||||||
DeltaCompEstimateAbs *float64 `json:"delta_comp_estimate_abs"`
|
|
||||||
DeltaCompEstimatePct *float64 `json:"delta_comp_estimate_pct"`
|
|
||||||
DeltaCompWhAbs *float64 `json:"delta_comp_wh_abs"`
|
|
||||||
DeltaCompWhPct *float64 `json:"delta_comp_wh_pct"`
|
|
||||||
PriceMissing []string `json:"price_missing"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PriceLevelsResult struct {
|
|
||||||
Items []PriceLevelsItem `json:"items"`
|
|
||||||
ResolvedPricelistIDs map[string]uint `json:"resolved_pricelist_ids"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidationResult, error) {
|
func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidationResult, error) {
|
||||||
if len(req.Items) == 0 {
|
if len(req.Items) == 0 {
|
||||||
return nil, ErrEmptyQuote
|
return nil, ErrEmptyQuote
|
||||||
@@ -165,132 +130,6 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) {
|
|
||||||
if len(req.Items) == 0 {
|
|
||||||
return nil, ErrEmptyQuote
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &PriceLevelsResult{
|
|
||||||
Items: make([]PriceLevelsItem, 0, len(req.Items)),
|
|
||||||
ResolvedPricelistIDs: map[string]uint{},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, reqItem := range req.Items {
|
|
||||||
item := PriceLevelsItem{
|
|
||||||
LotName: reqItem.LotName,
|
|
||||||
Quantity: reqItem.Quantity,
|
|
||||||
PriceMissing: make([]string, 0, 3),
|
|
||||||
}
|
|
||||||
|
|
||||||
estimatePrice, estimateID := s.lookupLevelPrice(models.PricelistSourceEstimate, reqItem.LotName, req.PricelistIDs)
|
|
||||||
warehousePrice, warehouseID := s.lookupLevelPrice(models.PricelistSourceWarehouse, reqItem.LotName, req.PricelistIDs)
|
|
||||||
competitorPrice, competitorID := s.lookupLevelPrice(models.PricelistSourceCompetitor, reqItem.LotName, req.PricelistIDs)
|
|
||||||
|
|
||||||
item.EstimatePrice = estimatePrice
|
|
||||||
item.WarehousePrice = warehousePrice
|
|
||||||
item.CompetitorPrice = competitorPrice
|
|
||||||
|
|
||||||
if estimateID != 0 {
|
|
||||||
result.ResolvedPricelistIDs[string(models.PricelistSourceEstimate)] = estimateID
|
|
||||||
}
|
|
||||||
if warehouseID != 0 {
|
|
||||||
result.ResolvedPricelistIDs[string(models.PricelistSourceWarehouse)] = warehouseID
|
|
||||||
}
|
|
||||||
if competitorID != 0 {
|
|
||||||
result.ResolvedPricelistIDs[string(models.PricelistSourceCompetitor)] = competitorID
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.EstimatePrice == nil {
|
|
||||||
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceEstimate))
|
|
||||||
}
|
|
||||||
if item.WarehousePrice == nil {
|
|
||||||
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceWarehouse))
|
|
||||||
}
|
|
||||||
if item.CompetitorPrice == nil {
|
|
||||||
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceCompetitor))
|
|
||||||
}
|
|
||||||
|
|
||||||
item.DeltaWhEstimateAbs, item.DeltaWhEstimatePct = calculateDelta(item.WarehousePrice, item.EstimatePrice)
|
|
||||||
item.DeltaCompEstimateAbs, item.DeltaCompEstimatePct = calculateDelta(item.CompetitorPrice, item.EstimatePrice)
|
|
||||||
item.DeltaCompWhAbs, item.DeltaCompWhPct = calculateDelta(item.CompetitorPrice, item.WarehousePrice)
|
|
||||||
|
|
||||||
result.Items = append(result.Items, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func calculateDelta(target, base *float64) (*float64, *float64) {
|
|
||||||
if target == nil || base == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
abs := *target - *base
|
|
||||||
if *base == 0 {
|
|
||||||
return &abs, nil
|
|
||||||
}
|
|
||||||
pct := (abs / *base) * 100
|
|
||||||
return &abs, &pct
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *QuoteService) lookupLevelPrice(source models.PricelistSource, lotName string, pricelistIDs map[string]uint) (*float64, uint) {
|
|
||||||
sourceKey := string(source)
|
|
||||||
if id, ok := pricelistIDs[sourceKey]; ok && id > 0 {
|
|
||||||
price, found := s.lookupPriceByPricelistID(id, lotName)
|
|
||||||
if found {
|
|
||||||
return &price, id
|
|
||||||
}
|
|
||||||
return nil, id
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pricelistRepo != nil {
|
|
||||||
price, id, err := s.pricelistRepo.GetPriceForLotBySource(sourceKey, lotName)
|
|
||||||
if err == nil && price > 0 {
|
|
||||||
return &price, id
|
|
||||||
}
|
|
||||||
|
|
||||||
latest, latestErr := s.pricelistRepo.GetLatestActiveBySource(sourceKey)
|
|
||||||
if latestErr == nil {
|
|
||||||
return nil, latest.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.localDB != nil {
|
|
||||||
localPL, err := s.localDB.GetLatestLocalPricelistBySource(sourceKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0
|
|
||||||
}
|
|
||||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
|
||||||
if err != nil || price <= 0 {
|
|
||||||
return nil, localPL.ServerID
|
|
||||||
}
|
|
||||||
return &price, localPL.ServerID
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string) (float64, bool) {
|
|
||||||
if s.pricelistRepo != nil {
|
|
||||||
price, err := s.pricelistRepo.GetPriceForLot(pricelistID, lotName)
|
|
||||||
if err == nil && price > 0 {
|
|
||||||
return price, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.localDB != nil {
|
|
||||||
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
|
||||||
if err == nil && price > 0 {
|
|
||||||
return price, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordUsage records that components were used in a quote
|
// RecordUsage records that components were used in a quote
|
||||||
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
|
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
|
||||||
if s.statsRepo == nil {
|
if s.statsRepo == nil {
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
||||||
"github.com/glebarez/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
|
||||||
db := newPriceLevelsTestDB(t)
|
|
||||||
repo := repository.NewPricelistRepository(db)
|
|
||||||
service := NewQuoteService(nil, nil, repo, nil, nil)
|
|
||||||
|
|
||||||
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
|
|
||||||
_ = estimate
|
|
||||||
seedPricelistWithItem(t, repo, "warehouse", "CPU_X", 120)
|
|
||||||
|
|
||||||
result, err := service.CalculatePriceLevels(&PriceLevelsRequest{
|
|
||||||
Items: []struct {
|
|
||||||
LotName string `json:"lot_name"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
}{
|
|
||||||
{LotName: "CPU_X", Quantity: 2},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CalculatePriceLevels returned error: %v", err)
|
|
||||||
}
|
|
||||||
if len(result.Items) != 1 {
|
|
||||||
t.Fatalf("expected 1 item, got %d", len(result.Items))
|
|
||||||
}
|
|
||||||
item := result.Items[0]
|
|
||||||
if item.EstimatePrice == nil || *item.EstimatePrice != 100 {
|
|
||||||
t.Fatalf("expected estimate 100, got %#v", item.EstimatePrice)
|
|
||||||
}
|
|
||||||
if item.WarehousePrice == nil || *item.WarehousePrice != 120 {
|
|
||||||
t.Fatalf("expected warehouse 120, got %#v", item.WarehousePrice)
|
|
||||||
}
|
|
||||||
if item.CompetitorPrice != nil {
|
|
||||||
t.Fatalf("expected competitor nil, got %#v", item.CompetitorPrice)
|
|
||||||
}
|
|
||||||
if len(item.PriceMissing) != 1 || item.PriceMissing[0] != "competitor" {
|
|
||||||
t.Fatalf("expected price_missing [competitor], got %#v", item.PriceMissing)
|
|
||||||
}
|
|
||||||
if item.DeltaWhEstimateAbs == nil || *item.DeltaWhEstimateAbs != 20 {
|
|
||||||
t.Fatalf("expected delta abs 20, got %#v", item.DeltaWhEstimateAbs)
|
|
||||||
}
|
|
||||||
if item.DeltaWhEstimatePct == nil || *item.DeltaWhEstimatePct != 20 {
|
|
||||||
t.Fatalf("expected delta pct 20, got %#v", item.DeltaWhEstimatePct)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
|
|
||||||
db := newPriceLevelsTestDB(t)
|
|
||||||
repo := repository.NewPricelistRepository(db)
|
|
||||||
service := NewQuoteService(nil, nil, repo, nil, nil)
|
|
||||||
|
|
||||||
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
|
|
||||||
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
|
|
||||||
|
|
||||||
result, err := service.CalculatePriceLevels(&PriceLevelsRequest{
|
|
||||||
Items: []struct {
|
|
||||||
LotName string `json:"lot_name"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
}{
|
|
||||||
{LotName: "CPU_Y", Quantity: 1},
|
|
||||||
},
|
|
||||||
PricelistIDs: map[string]uint{
|
|
||||||
"estimate": olderEstimate.ID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CalculatePriceLevels returned error: %v", err)
|
|
||||||
}
|
|
||||||
item := result.Items[0]
|
|
||||||
if item.EstimatePrice == nil || *item.EstimatePrice != 80 {
|
|
||||||
t.Fatalf("expected explicit estimate 80, got %#v", item.EstimatePrice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPriceLevelsTestDB(t *testing.T) *gorm.DB {
|
|
||||||
t.Helper()
|
|
||||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open sqlite: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
|
|
||||||
t.Fatalf("migrate: %v", err)
|
|
||||||
}
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedPricelistWithItem(t *testing.T, repo *repository.PricelistRepository, source, lot string, price float64) *models.Pricelist {
|
|
||||||
t.Helper()
|
|
||||||
version, err := repo.GenerateVersionBySource(source)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateVersionBySource: %v", err)
|
|
||||||
}
|
|
||||||
expiresAt := time.Now().Add(24 * time.Hour)
|
|
||||||
pl := &models.Pricelist{
|
|
||||||
Source: source,
|
|
||||||
Version: version,
|
|
||||||
CreatedBy: "test",
|
|
||||||
IsActive: true,
|
|
||||||
ExpiresAt: &expiresAt,
|
|
||||||
}
|
|
||||||
if err := repo.Create(pl); err != nil {
|
|
||||||
t.Fatalf("create pricelist: %v", err)
|
|
||||||
}
|
|
||||||
if err := repo.CreateItems([]models.PricelistItem{
|
|
||||||
{
|
|
||||||
PricelistID: pl.ID,
|
|
||||||
LotName: lot,
|
|
||||||
Price: price,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("create items: %v", err)
|
|
||||||
}
|
|
||||||
return pl
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||||
@@ -51,13 +49,6 @@ type SyncStatus struct {
|
|||||||
NeedsSync bool `json:"needs_sync"`
|
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.
|
// ConfigImportResult represents server->local configuration import stats.
|
||||||
type ConfigImportResult struct {
|
type ConfigImportResult struct {
|
||||||
Imported int `json:"imported"`
|
Imported int `json:"imported"`
|
||||||
@@ -65,13 +56,6 @@ type ConfigImportResult struct {
|
|||||||
Skipped int `json:"skipped"`
|
Skipped int `json:"skipped"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectImportResult represents server->local project import stats.
|
|
||||||
type ProjectImportResult struct {
|
|
||||||
Imported int `json:"imported"`
|
|
||||||
Updated int `json:"updated"`
|
|
||||||
Skipped int `json:"skipped"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigurationChangePayload is stored in pending_changes.payload for configuration events.
|
// ConfigurationChangePayload is stored in pending_changes.payload for configuration events.
|
||||||
// It carries version metadata so sync can push the latest snapshot and prepare for conflict resolution.
|
// It carries version metadata so sync can push the latest snapshot and prepare for conflict resolution.
|
||||||
type ConfigurationChangePayload struct {
|
type ConfigurationChangePayload struct {
|
||||||
@@ -79,7 +63,6 @@ type ConfigurationChangePayload struct {
|
|||||||
IdempotencyKey string `json:"idempotency_key"`
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
ConfigurationUUID string `json:"configuration_uuid"`
|
ConfigurationUUID string `json:"configuration_uuid"`
|
||||||
ProjectUUID *string `json:"project_uuid,omitempty"`
|
ProjectUUID *string `json:"project_uuid,omitempty"`
|
||||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
|
||||||
Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete
|
Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete
|
||||||
CurrentVersionID string `json:"current_version_id,omitempty"`
|
CurrentVersionID string `json:"current_version_id,omitempty"`
|
||||||
CurrentVersionNo int `json:"current_version_no,omitempty"`
|
CurrentVersionNo int `json:"current_version_no,omitempty"`
|
||||||
@@ -161,78 +144,6 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportProjectsToLocal imports projects from MariaDB into local SQLite.
|
|
||||||
// Existing local projects with pending local changes are skipped to avoid data loss.
|
|
||||||
func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
|
||||||
mariaDB, err := s.getDB()
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrOffline
|
|
||||||
}
|
|
||||||
|
|
||||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
|
||||||
result := &ProjectImportResult{}
|
|
||||||
|
|
||||||
offset := 0
|
|
||||||
const limit = 200
|
|
||||||
for {
|
|
||||||
serverProjects, _, err := projectRepo.List(offset, limit, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("listing server projects: %w", err)
|
|
||||||
}
|
|
||||||
if len(serverProjects) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
for i := range serverProjects {
|
|
||||||
project := serverProjects[i]
|
|
||||||
|
|
||||||
existing, getErr := s.localDB.GetProjectByUUID(project.UUID)
|
|
||||||
if getErr != nil && !errors.Is(getErr, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, fmt.Errorf("getting local project %s: %w", project.UUID, getErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing != nil && getErr == nil {
|
|
||||||
// Keep unsynced local changes intact.
|
|
||||||
if existing.SyncStatus == "pending" {
|
|
||||||
result.Skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.OwnerUsername = project.OwnerUsername
|
|
||||||
existing.Name = project.Name
|
|
||||||
existing.TrackerURL = project.TrackerURL
|
|
||||||
existing.IsActive = project.IsActive
|
|
||||||
existing.IsSystem = project.IsSystem
|
|
||||||
existing.CreatedAt = project.CreatedAt
|
|
||||||
existing.UpdatedAt = project.UpdatedAt
|
|
||||||
serverID := project.ID
|
|
||||||
existing.ServerID = &serverID
|
|
||||||
existing.SyncStatus = "synced"
|
|
||||||
existing.SyncedAt = &now
|
|
||||||
|
|
||||||
if err := s.localDB.SaveProject(existing); err != nil {
|
|
||||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
|
||||||
}
|
|
||||||
result.Updated++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
localProject := localdb.ProjectToLocal(&project)
|
|
||||||
localProject.SyncStatus = "synced"
|
|
||||||
localProject.SyncedAt = &now
|
|
||||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
|
||||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
|
||||||
}
|
|
||||||
result.Imported++
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += len(serverProjects)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStatus returns the current sync status
|
// GetStatus returns the current sync status
|
||||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||||
lastSync := s.localDB.GetLastSyncTime()
|
lastSync := s.localDB.GetLastSyncTime()
|
||||||
@@ -292,28 +203,21 @@ func (s *Service) NeedSync() (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
sources := []models.PricelistSource{
|
latestServer, err := pricelistRepo.GetLatestActive()
|
||||||
models.PricelistSourceEstimate,
|
if err != nil {
|
||||||
models.PricelistSourceWarehouse,
|
// If no pricelists on server, no need to sync
|
||||||
models.PricelistSourceCompetitor,
|
return false, nil
|
||||||
}
|
}
|
||||||
for _, source := range sources {
|
|
||||||
latestServer, err := pricelistRepo.GetLatestActiveBySource(string(source))
|
|
||||||
if err != nil {
|
|
||||||
// No active pricelist for this source yet.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
latestLocal, err := s.localDB.GetLatestLocalPricelistBySource(string(source))
|
latestLocal, err := s.localDB.GetLatestLocalPricelist()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No local pricelist for an existing source on server.
|
// No local pricelists, need to sync
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If server has newer pricelist for this source, need sync.
|
// If server has newer pricelist, need sync
|
||||||
if latestServer.ID != latestLocal.ServerID {
|
if latestServer.ID != latestLocal.ServerID {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -339,16 +243,16 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
synced := 0
|
synced := 0
|
||||||
var latestEstimateLocalID uint
|
var latestLocalID uint
|
||||||
var latestEstimateCreatedAt time.Time
|
var latestServerID uint
|
||||||
for _, pl := range serverPricelists {
|
for _, pl := range serverPricelists {
|
||||||
// Check if pricelist already exists locally
|
// Check if pricelist already exists locally
|
||||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
// Track latest estimate pricelist by created_at for component refresh.
|
// Already synced, track latest by server ID
|
||||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
if pl.ID > latestServerID {
|
||||||
latestEstimateCreatedAt = pl.CreatedAt
|
latestServerID = pl.ID
|
||||||
latestEstimateLocalID = existing.ID
|
latestLocalID = existing.ID
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -356,7 +260,6 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
// Create local pricelist
|
// Create local pricelist
|
||||||
localPL := &localdb.LocalPricelist{
|
localPL := &localdb.LocalPricelist{
|
||||||
ServerID: pl.ID,
|
ServerID: pl.ID,
|
||||||
Source: pl.Source,
|
|
||||||
Version: pl.Version,
|
Version: pl.Version,
|
||||||
Name: pl.Notification, // Using notification as name
|
Name: pl.Notification, // Using notification as name
|
||||||
CreatedAt: pl.CreatedAt,
|
CreatedAt: pl.CreatedAt,
|
||||||
@@ -378,16 +281,16 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
if pl.ID > latestServerID {
|
||||||
latestEstimateCreatedAt = pl.CreatedAt
|
latestServerID = pl.ID
|
||||||
latestEstimateLocalID = localPL.ID
|
latestLocalID = localPL.ID
|
||||||
}
|
}
|
||||||
synced++
|
synced++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update component prices from latest estimate pricelist only.
|
// Update component prices from latest pricelist
|
||||||
if latestEstimateLocalID > 0 {
|
if latestLocalID > 0 {
|
||||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID)
|
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to update component prices from pricelist", "error", err)
|
slog.Warn("failed to update component prices from pricelist", "error", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -397,168 +300,11 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
|
|
||||||
// Update last sync time
|
// Update last sync time
|
||||||
s.localDB.SetLastSyncTime(time.Now())
|
s.localDB.SetLastSyncTime(time.Now())
|
||||||
s.RecordSyncHeartbeat()
|
|
||||||
|
|
||||||
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||||
return synced, nil
|
return synced, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
|
|
||||||
// Only users with write rights are expected to be able to update this table.
|
|
||||||
func (s *Service) RecordSyncHeartbeat() {
|
|
||||||
username := strings.TrimSpace(s.localDB.GetDBUser())
|
|
||||||
if username == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mariaDB, err := s.getDB()
|
|
||||||
if err != nil || mariaDB == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
|
|
||||||
slog.Warn("sync heartbeat: failed to ensure table", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
if err := mariaDB.Exec(`
|
|
||||||
INSERT INTO qt_pricelist_sync_status (username, last_sync_at, updated_at, app_version)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
last_sync_at = VALUES(last_sync_at),
|
|
||||||
updated_at = VALUES(updated_at),
|
|
||||||
app_version = VALUES(app_version)
|
|
||||||
`, username, now, now, appmeta.Version()).Error; err != nil {
|
|
||||||
slog.Debug("sync heartbeat: skipped", "username", username, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListUserSyncStatuses returns users who have recorded sync heartbeat.
|
|
||||||
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
|
|
||||||
mariaDB, err := s.getDB()
|
|
||||||
if err != nil || mariaDB == nil {
|
|
||||||
return nil, ErrOffline
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
|
|
||||||
return nil, fmt.Errorf("ensure sync status table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type row struct {
|
|
||||||
Username string `gorm:"column:username"`
|
|
||||||
LastSyncAt time.Time `gorm:"column:last_sync_at"`
|
|
||||||
AppVersion string `gorm:"column:app_version"`
|
|
||||||
}
|
|
||||||
var rows []row
|
|
||||||
if err := mariaDB.Raw(`
|
|
||||||
SELECT username, last_sync_at, COALESCE(app_version, '') AS app_version
|
|
||||||
FROM qt_pricelist_sync_status
|
|
||||||
ORDER BY last_sync_at DESC, username ASC
|
|
||||||
`).Scan(&rows).Error; err != nil {
|
|
||||||
return nil, fmt.Errorf("load sync status rows: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
activeUsers, err := s.listConnectedDBUsers(mariaDB)
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("sync status: failed to load connected DB users", "error", err)
|
|
||||||
activeUsers = map[string]struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
result := make([]UserSyncStatus, 0, len(rows)+len(activeUsers))
|
|
||||||
for i := range rows {
|
|
||||||
r := rows[i]
|
|
||||||
username := strings.TrimSpace(r.Username)
|
|
||||||
if username == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
isOnline := now.Sub(r.LastSyncAt) <= onlineThreshold
|
|
||||||
if _, connected := activeUsers[username]; connected {
|
|
||||||
isOnline = true
|
|
||||||
delete(activeUsers, username)
|
|
||||||
}
|
|
||||||
|
|
||||||
appVersion := strings.TrimSpace(r.AppVersion)
|
|
||||||
|
|
||||||
result = append(result, UserSyncStatus{
|
|
||||||
Username: username,
|
|
||||||
LastSyncAt: r.LastSyncAt,
|
|
||||||
AppVersion: appVersion,
|
|
||||||
IsOnline: isOnline,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for username := range activeUsers {
|
|
||||||
result = append(result, UserSyncStatus{
|
|
||||||
Username: username,
|
|
||||||
LastSyncAt: now,
|
|
||||||
AppVersion: "",
|
|
||||||
IsOnline: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.SliceStable(result, func(i, j int) bool {
|
|
||||||
if result[i].IsOnline != result[j].IsOnline {
|
|
||||||
return result[i].IsOnline
|
|
||||||
}
|
|
||||||
if result[i].LastSyncAt.Equal(result[j].LastSyncAt) {
|
|
||||||
return strings.ToLower(result[i].Username) < strings.ToLower(result[j].Username)
|
|
||||||
}
|
|
||||||
return result[i].LastSyncAt.After(result[j].LastSyncAt)
|
|
||||||
})
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, error) {
|
|
||||||
type processUserRow struct {
|
|
||||||
Username string `gorm:"column:username"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []processUserRow
|
|
||||||
if err := mariaDB.Raw(`
|
|
||||||
SELECT DISTINCT TRIM(USER) AS username
|
|
||||||
FROM information_schema.PROCESSLIST
|
|
||||||
WHERE COALESCE(TRIM(USER), '') <> ''
|
|
||||||
AND DB = DATABASE()
|
|
||||||
`).Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
users := make(map[string]struct{}, len(rows))
|
|
||||||
for i := range rows {
|
|
||||||
username := strings.TrimSpace(rows[i].Username)
|
|
||||||
if username == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
users[username] = struct{}{}
|
|
||||||
}
|
|
||||||
return users, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureUserSyncStatusTable(db *gorm.DB) error {
|
|
||||||
if err := db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
|
||||||
username VARCHAR(100) NOT NULL,
|
|
||||||
last_sync_at DATETIME NOT NULL,
|
|
||||||
updated_at DATETIME NOT NULL,
|
|
||||||
app_version VARCHAR(64) NULL,
|
|
||||||
PRIMARY KEY (username),
|
|
||||||
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
|
|
||||||
)
|
|
||||||
`).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility for environments where table was created without app_version.
|
|
||||||
return db.Exec(`
|
|
||||||
ALTER TABLE qt_pricelist_sync_status
|
|
||||||
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
|
|
||||||
`).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncPricelistItems synchronizes items for a specific pricelist
|
// SyncPricelistItems synchronizes items for a specific pricelist
|
||||||
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||||
// Get local pricelist
|
// Get local pricelist
|
||||||
@@ -766,8 +512,20 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
|||||||
project := payload.Snapshot
|
project := payload.Snapshot
|
||||||
project.UUID = payload.ProjectUUID
|
project.UUID = payload.ProjectUUID
|
||||||
|
|
||||||
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
serverProject, err := projectRepo.GetByUUID(project.UUID)
|
||||||
return fmt.Errorf("upsert project on server: %w", err)
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
if createErr := projectRepo.Create(&project); createErr != nil {
|
||||||
|
return fmt.Errorf("create project on server: %w", createErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("get project on server: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
project.ID = serverProject.ID
|
||||||
|
if updateErr := projectRepo.Update(&project); updateErr != nil {
|
||||||
|
return fmt.Errorf("update project on server: %w", updateErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||||
@@ -852,9 +610,6 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
|||||||
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
||||||
return fmt.Errorf("resolve configuration project: %w", err)
|
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
|
// Create on server
|
||||||
if err := configRepo.Create(&cfg); err != nil {
|
if err := configRepo.Create(&cfg); err != nil {
|
||||||
@@ -913,9 +668,6 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
|||||||
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
||||||
return fmt.Errorf("resolve configuration project: %w", err)
|
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
|
// Ensure we have a server ID before updating
|
||||||
// If the payload doesn't have ID, get it from local configuration
|
// If the payload doesn't have ID, get it from local configuration
|
||||||
@@ -926,34 +678,15 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if localCfg.ServerID == nil {
|
if localCfg.ServerID == nil {
|
||||||
// Configuration hasn't been synced yet, try to find it on server by UUID.
|
// 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, err := configRepo.GetByUUID(cfg.UUID)
|
||||||
serverCfg, getErr := configRepo.GetByUUID(cfg.UUID)
|
if err != nil {
|
||||||
if getErr != nil {
|
return fmt.Errorf("configuration not yet synced to server: %w", err)
|
||||||
if !errors.Is(getErr, gorm.ErrRecordNotFound) {
|
|
||||||
return fmt.Errorf("loading configuration from server: %w", getErr)
|
|
||||||
}
|
|
||||||
if createErr := configRepo.Create(&cfg); createErr != nil {
|
|
||||||
// Idempotency fallback: configuration may have been created concurrently.
|
|
||||||
existing, existingErr := configRepo.GetByUUID(cfg.UUID)
|
|
||||||
if existingErr != nil {
|
|
||||||
return fmt.Errorf("creating missing configuration on server: %w", createErr)
|
|
||||||
}
|
|
||||||
cfg.ID = existing.ID
|
|
||||||
}
|
|
||||||
if cfg.ID == 0 {
|
|
||||||
existing, existingErr := configRepo.GetByUUID(cfg.UUID)
|
|
||||||
if existingErr != nil {
|
|
||||||
return fmt.Errorf("loading created configuration from server: %w", existingErr)
|
|
||||||
}
|
|
||||||
cfg.ID = existing.ID
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cfg.ID = serverCfg.ID
|
|
||||||
}
|
}
|
||||||
|
cfg.ID = serverCfg.ID
|
||||||
|
|
||||||
// Update local with server ID
|
// Update local with server ID
|
||||||
serverID := cfg.ID
|
serverID := serverCfg.ID
|
||||||
localCfg.ServerID = &serverID
|
localCfg.ServerID = &serverID
|
||||||
s.localDB.SaveConfiguration(localCfg)
|
s.localDB.SaveConfiguration(localCfg)
|
||||||
} else {
|
} else {
|
||||||
@@ -1029,7 +762,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
|||||||
if modelProject.OwnerUsername == "" {
|
if modelProject.OwnerUsername == "" {
|
||||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||||
}
|
}
|
||||||
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
if createErr := projectRepo.Create(modelProject); createErr != nil {
|
||||||
return createErr
|
return createErr
|
||||||
}
|
}
|
||||||
if modelProject.ID > 0 {
|
if modelProject.ID > 0 {
|
||||||
@@ -1068,29 +801,6 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
|||||||
return nil
|
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 {
|
func (s *Service) pushConfigurationRollback(change *localdb.PendingChange) error {
|
||||||
// Last-write-wins for now: rollback is pushed as an update with rollback metadata.
|
// Last-write-wins for now: rollback is pushed as an update with rollback metadata.
|
||||||
return s.pushConfigurationUpdate(change)
|
return s.pushConfigurationUpdate(change)
|
||||||
@@ -1138,7 +848,6 @@ func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChan
|
|||||||
if currentVersionNo > 0 {
|
if currentVersionNo > 0 {
|
||||||
payload.CurrentVersionNo = currentVersionNo
|
payload.CurrentVersionNo = currentVersionNo
|
||||||
}
|
}
|
||||||
payload.PricelistID = currentCfg.PricelistID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isStale := false
|
isStale := false
|
||||||
@@ -1176,7 +885,6 @@ func decodeConfigurationChangePayload(change *localdb.PendingChange) (Configurat
|
|||||||
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation),
|
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation),
|
||||||
ConfigurationUUID: cfg.UUID,
|
ConfigurationUUID: cfg.UUID,
|
||||||
ProjectUUID: cfg.ProjectUUID,
|
ProjectUUID: cfg.ProjectUUID,
|
||||||
PricelistID: cfg.PricelistID,
|
|
||||||
Operation: change.Operation,
|
Operation: change.Operation,
|
||||||
ConflictPolicy: "last_write_wins",
|
ConflictPolicy: "last_write_wins",
|
||||||
Snapshot: cfg,
|
Snapshot: cfg,
|
||||||
|
|||||||
@@ -65,54 +65,6 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T) {
|
|
||||||
local := newLocalDBForSyncTest(t)
|
|
||||||
serverDB := newServerDBForSyncTest(t)
|
|
||||||
|
|
||||||
localSync := syncsvc.NewService(nil, local)
|
|
||||||
projectService := services.NewProjectService(local)
|
|
||||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
|
||||||
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
|
||||||
|
|
||||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project v1"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create project: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
|
|
||||||
t.Fatalf("update project: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := configService.Create("tester", &services.CreateConfigRequest{
|
|
||||||
Name: "Cfg linked",
|
|
||||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
|
||||||
ServerCount: 1,
|
|
||||||
ProjectUUID: &project.UUID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := pushService.PushPendingChanges(); err != nil {
|
|
||||||
t.Fatalf("push pending changes: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var serverProject models.Project
|
|
||||||
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
|
|
||||||
t.Fatalf("project not pushed to server: %v", err)
|
|
||||||
}
|
|
||||||
if serverProject.Name != "Project v2" {
|
|
||||||
t.Fatalf("expected latest project name, got %q", serverProject.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
var serverCfg models.Configuration
|
|
||||||
if err := serverDB.Where("uuid = ?", cfg.UUID).First(&serverCfg).Error; err != nil {
|
|
||||||
t.Fatalf("configuration not pushed to server: %v", err)
|
|
||||||
}
|
|
||||||
if serverCfg.ProjectUUID == nil || *serverCfg.ProjectUUID != project.UUID {
|
|
||||||
t.Fatalf("expected project_uuid=%s on pushed config, got %v", project.UUID, serverCfg.ProjectUUID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(t *testing.T) {
|
func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(t *testing.T) {
|
||||||
local := newLocalDBForSyncTest(t)
|
local := newLocalDBForSyncTest(t)
|
||||||
serverDB := newServerDBForSyncTest(t)
|
serverDB := newServerDBForSyncTest(t)
|
||||||
@@ -250,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 {
|
func newLocalDBForSyncTest(t *testing.T) *localdb.LocalDB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
localPath := filepath.Join(t.TempDir(), "local.db")
|
localPath := filepath.Join(t.TempDir(), "local.db")
|
||||||
@@ -325,7 +226,6 @@ CREATE TABLE qt_projects (
|
|||||||
uuid TEXT NOT NULL UNIQUE,
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
owner_username TEXT NOT NULL,
|
owner_username TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
tracker_url TEXT NULL,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
is_system INTEGER NOT NULL DEFAULT 0,
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME,
|
created_at DATETIME,
|
||||||
@@ -348,7 +248,6 @@ CREATE TABLE qt_configurations (
|
|||||||
notes TEXT NULL,
|
notes TEXT NULL,
|
||||||
is_template INTEGER NOT NULL DEFAULT 0,
|
is_template INTEGER NOT NULL DEFAULT 0,
|
||||||
server_count INTEGER NOT NULL DEFAULT 1,
|
server_count INTEGER NOT NULL DEFAULT 1,
|
||||||
pricelist_id INTEGER NULL,
|
|
||||||
price_updated_at DATETIME NULL,
|
price_updated_at DATETIME NULL,
|
||||||
created_at DATETIME
|
created_at DATETIME
|
||||||
);`).Error; err != nil {
|
);`).Error; err != nil {
|
||||||
|
|||||||
@@ -83,11 +83,7 @@ func (w *Worker) runSync() {
|
|||||||
err = w.service.SyncPricelistsIfNeeded()
|
err = w.service.SyncPricelistsIfNeeded()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.logger.Warn("background sync: failed to sync pricelists", "error", err)
|
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")
|
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,7 +0,0 @@
|
|||||||
ALTER TABLE qt_projects
|
|
||||||
ADD COLUMN tracker_url VARCHAR(500) NULL AFTER name;
|
|
||||||
|
|
||||||
UPDATE qt_projects
|
|
||||||
SET tracker_url = CONCAT('https://tracker.yandex.ru/', TRIM(name))
|
|
||||||
WHERE (tracker_url IS NULL OR tracker_url = '')
|
|
||||||
AND TRIM(COALESCE(name, '')) <> '';
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
ALTER TABLE qt_pricelists
|
|
||||||
ADD COLUMN IF NOT EXISTS source ENUM('estimate', 'warehouse', 'competitor') NOT NULL DEFAULT 'estimate' AFTER id;
|
|
||||||
|
|
||||||
UPDATE qt_pricelists
|
|
||||||
SET source = 'estimate'
|
|
||||||
WHERE source IS NULL OR source = '';
|
|
||||||
|
|
||||||
ALTER TABLE qt_pricelists
|
|
||||||
DROP INDEX IF EXISTS idx_qt_pricelists_version;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX idx_qt_pricelists_source_version
|
|
||||||
ON qt_pricelists(source, version);
|
|
||||||
|
|
||||||
CREATE INDEX idx_qt_pricelists_source_created_at
|
|
||||||
ON qt_pricelists(source, created_at);
|
|
||||||
@@ -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('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('components')" id="btn-components" class="text-gray-600">Компоненты</button>
|
||||||
<button onclick="loadTab('pricelists')" id="btn-pricelists" 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>
|
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
|
||||||
</div>
|
</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 onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
|
||||||
Обновить цены
|
Пересчитать цены
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
|
<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">
|
<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>
|
<span id="progress-percent" class="font-bold">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 rounded-full h-4">
|
<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 id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
|
||||||
</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 -->
|
<!-- Create Modal -->
|
||||||
<div id="pricelists-create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<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">
|
<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>
|
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
|
||||||
</p>
|
</p>
|
||||||
<form id="pricelists-create-form" class="space-y-4">
|
<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">
|
<div class="flex justify-end space-x-3">
|
||||||
<button type="button" onclick="closePricelistsCreateModal()"
|
<button type="button" onclick="closePricelistsCreateModal()"
|
||||||
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
|
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 pricelistsCanWrite = false;
|
||||||
let isCreatingPricelist = false;
|
let isCreatingPricelist = false;
|
||||||
let cachedDbUsername = null;
|
let cachedDbUsername = null;
|
||||||
let syncUsersStatusTimer = null;
|
|
||||||
|
|
||||||
async function loadTab(tab) {
|
async function loadTab(tab) {
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
currentSearch = '';
|
currentSearch = '';
|
||||||
document.getElementById('search-input').value = '';
|
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-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-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-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';
|
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
|
||||||
|
|
||||||
// Show/hide elements based on tab
|
// 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('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('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
|
||||||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||||
document.getElementById('sync-status-tab-content').className = 'hidden';
|
|
||||||
document.getElementById('tab-content').className = '';
|
document.getElementById('tab-content').className = '';
|
||||||
} else if (tab === 'all-configs') {
|
} else if (tab === 'all-configs') {
|
||||||
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for 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('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('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
|
||||||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||||
document.getElementById('sync-status-tab-content').className = 'hidden';
|
|
||||||
document.getElementById('tab-content').className = '';
|
document.getElementById('tab-content').className = '';
|
||||||
} else if (tab === 'pricelists') {
|
} else if (tab === 'pricelists') {
|
||||||
document.getElementById('search-bar').className = 'mb-4 hidden';
|
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||||
document.getElementById('pagination').className = 'hidden';
|
document.getElementById('pagination').className = 'hidden';
|
||||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||||
document.getElementById('pricelists-tab-content').className = '';
|
document.getElementById('pricelists-tab-content').className = '';
|
||||||
document.getElementById('sync-status-tab-content').className = 'hidden';
|
|
||||||
document.getElementById('tab-content').className = 'hidden';
|
document.getElementById('tab-content').className = 'hidden';
|
||||||
// Load pricelists when pricelists tab is selected
|
// Load pricelists when pricelists tab is selected
|
||||||
checkPricelistWritePermission();
|
checkPricelistWritePermission();
|
||||||
loadPricelists(1);
|
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 {
|
} else {
|
||||||
document.getElementById('search-bar').className = 'mb-4 hidden';
|
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||||
document.getElementById('pagination').className = 'hidden';
|
document.getElementById('pagination').className = 'hidden';
|
||||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||||
document.getElementById('sync-status-tab-content').className = 'hidden';
|
|
||||||
document.getElementById('tab-content').className = '';
|
document.getElementById('tab-content').className = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab !== 'pricelists' && tab !== 'sync-status') {
|
if (tab !== 'pricelists') {
|
||||||
await loadData();
|
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() {
|
async function loadData() {
|
||||||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
|
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
|
// Show progress bar IMMEDIATELY
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Обновление...';
|
btn.textContent = 'Пересчёт...';
|
||||||
progressContainer.style.display = 'block';
|
progressContainer.style.display = 'block';
|
||||||
progressBar.style.width = '0%';
|
progressBar.style.width = '0%';
|
||||||
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
|
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
|
||||||
progressText.textContent = 'Запуск обновления...';
|
progressText.textContent = 'Запуск пересчёта...';
|
||||||
progressPercent.textContent = '0%';
|
progressPercent.textContent = '0%';
|
||||||
progressStats.textContent = 'Подготовка...';
|
progressStats.textContent = 'Подготовка...';
|
||||||
|
|
||||||
@@ -843,7 +769,7 @@ function recalculateAll() {
|
|||||||
reader.read().then(({done, value}) => {
|
reader.read().then(({done, value}) => {
|
||||||
if (done) {
|
if (done) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Обновить цены';
|
btn.textContent = 'Пересчитать цены';
|
||||||
progressText.textContent = 'Готово!';
|
progressText.textContent = 'Готово!';
|
||||||
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -868,7 +794,7 @@ function recalculateAll() {
|
|||||||
progressPercent.textContent = percent + '%';
|
progressPercent.textContent = percent + '%';
|
||||||
|
|
||||||
if (data.status === 'completed') {
|
if (data.status === 'completed') {
|
||||||
progressText.textContent = 'Обновление завершено!';
|
progressText.textContent = 'Пересчёт завершён!';
|
||||||
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
||||||
} else {
|
} else {
|
||||||
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
|
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
|
||||||
@@ -890,7 +816,7 @@ function recalculateAll() {
|
|||||||
console.error('Fetch error:', e);
|
console.error('Fetch error:', e);
|
||||||
alert('Ошибка соединения');
|
alert('Ошибка соединения');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Обновить цены';
|
btn.textContent = 'Пересчитать цены';
|
||||||
progressContainer.style.display = 'none';
|
progressContainer.style.display = 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -966,12 +892,11 @@ function renderAllConfigs(configs) {
|
|||||||
document.getElementById('tab-content').innerHTML = html;
|
document.getElementById('tab-content').innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
await checkPricelistWritePermission();
|
|
||||||
// Check URL params for initial tab
|
// Check URL params for initial tab
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const initialTab = urlParams.get('tab') || 'alerts';
|
const initialTab = urlParams.get('tab') || 'alerts';
|
||||||
await loadTab(initialTab);
|
loadTab(initialTab);
|
||||||
|
|
||||||
// Add event listeners for preview updates
|
// Add event listeners for preview updates
|
||||||
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
||||||
@@ -995,89 +920,9 @@ async function checkPricelistWritePermission() {
|
|||||||
Создать прайслист
|
Создать прайслист
|
||||||
</button>
|
</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) {
|
} catch (e) {
|
||||||
console.error('Failed to check pricelist write permission:', 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 ? 'Активен' : 'Неактивен';
|
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
|
||||||
|
|
||||||
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
|
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) {
|
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>`;
|
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;
|
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) {
|
function renderPricelistsPagination(total, page, perPage) {
|
||||||
const totalPages = Math.ceil(total / perPage);
|
const totalPages = Math.ceil(total / perPage);
|
||||||
if (totalPages <= 1) {
|
if (totalPages <= 1) {
|
||||||
@@ -1210,7 +1024,6 @@ async function loadPricelistsDbUsername() {
|
|||||||
function openPricelistsCreateModal() {
|
function openPricelistsCreateModal() {
|
||||||
document.getElementById('pricelists-create-modal').classList.remove('hidden');
|
document.getElementById('pricelists-create-modal').classList.remove('hidden');
|
||||||
document.getElementById('pricelists-create-modal').classList.add('flex');
|
document.getElementById('pricelists-create-modal').classList.add('flex');
|
||||||
resetPricelistCreateProgress();
|
|
||||||
loadPricelistsDbUsername();
|
loadPricelistsDbUsername();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1236,88 +1049,20 @@ async function createPricelist() {
|
|||||||
throw new Error('Создание прайслистов доступно только в онлайн режиме');
|
throw new Error('Создание прайслистов доступно только в онлайн режиме');
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressBox = document.getElementById('pricelist-create-progress');
|
const resp = await fetch('/api/pricelists', {
|
||||||
const progressBar = document.getElementById('pricelist-create-progress-bar');
|
method: 'POST',
|
||||||
const progressText = document.getElementById('pricelist-create-progress-text');
|
headers: {
|
||||||
const progressPercent = document.getElementById('pricelist-create-progress-percent');
|
'Content-Type': 'application/json'
|
||||||
const progressStats = document.getElementById('pricelist-create-progress-stats');
|
},
|
||||||
|
body: JSON.stringify({})
|
||||||
progressBox.classList.remove('hidden');
|
|
||||||
progressBar.style.width = '0%';
|
|
||||||
progressText.textContent = 'Запуск создания прайслиста...';
|
|
||||||
progressPercent.textContent = '0%';
|
|
||||||
progressStats.textContent = '';
|
|
||||||
|
|
||||||
const resp = await fetch('/api/pricelists/create-with-progress', {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
let data = {};
|
const data = await resp.json();
|
||||||
try { data = await resp.json(); } catch (_) {}
|
|
||||||
throw new Error(data.error || 'Failed to create pricelist');
|
throw new Error(data.error || 'Failed to create pricelist');
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = resp.body.getReader();
|
return await resp.json();
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let completedPricelist = null;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
const text = decoder.decode(value);
|
|
||||||
const lines = text.split('\n');
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.startsWith('data:')) continue;
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = JSON.parse(line.slice(5).trim());
|
|
||||||
} catch (_) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = Number(data.current || 0);
|
|
||||||
const total = Number(data.total || 0);
|
|
||||||
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
|
||||||
progressBar.style.width = percent + '%';
|
|
||||||
progressPercent.textContent = percent + '%';
|
|
||||||
if (data.lot_name) {
|
|
||||||
progressText.textContent = (data.message || 'Обработка') + ': ' + data.lot_name;
|
|
||||||
} else {
|
|
||||||
progressText.textContent = data.message || 'Обработка...';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.updated !== undefined || data.errors !== undefined) {
|
|
||||||
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ошибок: ' + (data.errors || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.status === 'error') {
|
|
||||||
throw new Error(data.message || 'Ошибка создания прайслиста');
|
|
||||||
}
|
|
||||||
if (data.status === 'completed' && data.pricelist) {
|
|
||||||
completedPricelist = data.pricelist;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!completedPricelist) {
|
|
||||||
throw new Error('Создание прервано: не получен результат');
|
|
||||||
}
|
|
||||||
return completedPricelist;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetPricelistCreateProgress() {
|
|
||||||
const progressBox = document.getElementById('pricelist-create-progress');
|
|
||||||
const progressBar = document.getElementById('pricelist-create-progress-bar');
|
|
||||||
const progressText = document.getElementById('pricelist-create-progress-text');
|
|
||||||
const progressPercent = document.getElementById('pricelist-create-progress-percent');
|
|
||||||
const progressStats = document.getElementById('pricelist-create-progress-stats');
|
|
||||||
progressBox.classList.add('hidden');
|
|
||||||
progressBar.style.width = '0%';
|
|
||||||
progressText.textContent = 'Подготовка...';
|
|
||||||
progressPercent.textContent = '0%';
|
|
||||||
progressStats.textContent = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePricelist(id) {
|
async function deletePricelist(id) {
|
||||||
|
|||||||
@@ -63,16 +63,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||||
<input id="create-project-input"
|
<select id="create-project-select"
|
||||||
list="create-project-options"
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
placeholder="Начните вводить название проекта"
|
<option value="">Без проекта</option>
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
</select>
|
||||||
<datalist id="create-project-options"></datalist>
|
|
||||||
<div class="mt-2 flex justify-between items-center gap-3">
|
|
||||||
<button type="button" onclick="clearCreateProjectInput()" class="text-sm text-gray-600 hover:text-gray-800">
|
|
||||||
Без проекта
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -177,10 +171,10 @@
|
|||||||
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
||||||
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. Создать и привязать квоту?</p>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||||
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
<button onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,8 +190,6 @@ let projectsCache = [];
|
|||||||
let projectNameByUUID = {};
|
let projectNameByUUID = {};
|
||||||
let pendingMoveConfigUUID = '';
|
let pendingMoveConfigUUID = '';
|
||||||
let pendingMoveProjectName = '';
|
let pendingMoveProjectName = '';
|
||||||
let pendingCreateConfigName = '';
|
|
||||||
let pendingCreateProjectName = '';
|
|
||||||
|
|
||||||
function renderConfigs(configs) {
|
function renderConfigs(configs) {
|
||||||
const emptyText = configStatusMode === 'archived'
|
const emptyText = configStatusMode === 'archived'
|
||||||
@@ -415,7 +407,6 @@ async function cloneConfig() {
|
|||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
document.getElementById('opportunity-number').value = '';
|
document.getElementById('opportunity-number').value = '';
|
||||||
document.getElementById('create-project-input').value = '';
|
|
||||||
document.getElementById('create-modal').classList.remove('hidden');
|
document.getElementById('create-modal').classList.remove('hidden');
|
||||||
document.getElementById('create-modal').classList.add('flex');
|
document.getElementById('create-modal').classList.add('flex');
|
||||||
document.getElementById('opportunity-number').focus();
|
document.getElementById('opportunity-number').focus();
|
||||||
@@ -434,25 +425,8 @@ async function createConfig() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectName = document.getElementById('create-project-input').value.trim();
|
const projectUUID = document.getElementById('create-project-select').value;
|
||||||
let projectUUID = '';
|
|
||||||
|
|
||||||
if (projectName) {
|
|
||||||
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
|
|
||||||
if (existingProject) {
|
|
||||||
projectUUID = existingProject.uuid;
|
|
||||||
} else {
|
|
||||||
pendingCreateConfigName = name;
|
|
||||||
pendingCreateProjectName = projectName;
|
|
||||||
openCreateProjectOnCreateModal(projectName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await createConfigWithProject(name, projectUUID);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createConfigWithProject(name, projectUUID) {
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/configs', {
|
const resp = await fetch('/api/configs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -468,17 +442,16 @@ async function createConfigWithProject(name, projectUUID) {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = await resp.json();
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
alert('Ошибка: ' + (config.error || 'Не удалось создать'));
|
const err = await resp.json();
|
||||||
return false;
|
alert('Ошибка: ' + (err.error || 'Не удалось создать'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const config = await resp.json();
|
||||||
window.location.href = '/configurator?uuid=' + config.uuid;
|
window.location.href = '/configurator?uuid=' + config.uuid;
|
||||||
return true;
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Ошибка создания конфигурации');
|
alert('Ошибка создания конфигурации');
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,22 +510,8 @@ function clearMoveProjectInput() {
|
|||||||
document.getElementById('move-project-input').value = '';
|
document.getElementById('move-project-input').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCreateProjectInput() {
|
|
||||||
document.getElementById('create-project-input').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateProjectOnMoveModal(projectName) {
|
function openCreateProjectOnMoveModal(projectName) {
|
||||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
document.getElementById('create-project-on-move-name').textContent = projectName;
|
||||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
|
|
||||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
|
|
||||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('create-project-on-move-modal').classList.add('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateProjectOnCreateModal(projectName) {
|
|
||||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
|
||||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
|
|
||||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
|
|
||||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||||
document.getElementById('create-project-on-move-modal').classList.add('flex');
|
document.getElementById('create-project-on-move-modal').classList.add('flex');
|
||||||
}
|
}
|
||||||
@@ -562,43 +521,9 @@ function closeCreateProjectOnMoveModal() {
|
|||||||
document.getElementById('create-project-on-move-modal').classList.remove('flex');
|
document.getElementById('create-project-on-move-modal').classList.remove('flex');
|
||||||
pendingMoveConfigUUID = '';
|
pendingMoveConfigUUID = '';
|
||||||
pendingMoveProjectName = '';
|
pendingMoveProjectName = '';
|
||||||
pendingCreateConfigName = '';
|
|
||||||
pendingCreateProjectName = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmCreateProjectOnMove() {
|
async function confirmCreateProjectOnMove() {
|
||||||
if (pendingCreateConfigName && pendingCreateProjectName) {
|
|
||||||
const configName = pendingCreateConfigName;
|
|
||||||
const projectName = pendingCreateProjectName;
|
|
||||||
try {
|
|
||||||
const createResp = await fetch('/api/projects', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({ name: projectName })
|
|
||||||
});
|
|
||||||
if (!createResp.ok) {
|
|
||||||
const err = await createResp.json();
|
|
||||||
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newProject = await createResp.json();
|
|
||||||
pendingCreateConfigName = '';
|
|
||||||
pendingCreateProjectName = '';
|
|
||||||
await loadProjectsForConfigUI();
|
|
||||||
const created = await createConfigWithProject(configName, newProject.uuid);
|
|
||||||
if (created) {
|
|
||||||
closeCreateProjectOnMoveModal();
|
|
||||||
} else {
|
|
||||||
closeCreateProjectOnMoveModal();
|
|
||||||
document.getElementById('create-project-input').value = projectName;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('Ошибка создания проекта');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configUUID = pendingMoveConfigUUID;
|
const configUUID = pendingMoveConfigUUID;
|
||||||
const projectName = pendingMoveProjectName;
|
const projectName = pendingMoveProjectName;
|
||||||
if (!configUUID || !projectName) {
|
if (!configUUID || !projectName) {
|
||||||
@@ -619,15 +544,10 @@ async function confirmCreateProjectOnMove() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newProject = await createResp.json();
|
const newProject = await createResp.json();
|
||||||
pendingMoveConfigUUID = '';
|
|
||||||
pendingMoveProjectName = '';
|
|
||||||
await loadProjectsForConfigUI();
|
|
||||||
document.getElementById('move-project-input').value = projectName;
|
|
||||||
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
||||||
if (moved) {
|
if (moved) {
|
||||||
closeCreateProjectOnMoveModal();
|
closeCreateProjectOnMoveModal();
|
||||||
} else {
|
closeMoveProjectModal();
|
||||||
closeCreateProjectOnMoveModal();
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка создания проекта');
|
alert('Ошибка создания проекта');
|
||||||
@@ -840,18 +760,16 @@ async function loadProjectsForConfigUI() {
|
|||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
projectsCache = (data.projects || []);
|
projectsCache = (data.projects || []);
|
||||||
|
|
||||||
projectsCache.forEach(project => {
|
const select = document.getElementById('create-project-select');
|
||||||
projectNameByUUID[project.uuid] = project.name;
|
if (select) {
|
||||||
});
|
select.innerHTML = '<option value="">Без проекта</option>';
|
||||||
|
|
||||||
const createOptions = document.getElementById('create-project-options');
|
|
||||||
if (createOptions) {
|
|
||||||
createOptions.innerHTML = '';
|
|
||||||
projectsCache.forEach(project => {
|
projectsCache.forEach(project => {
|
||||||
|
projectNameByUUID[project.uuid] = project.name;
|
||||||
if (!project.is_active) return;
|
if (!project.is_active) return;
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = project.name;
|
option.value = project.uuid;
|
||||||
createOptions.appendChild(option);
|
option.textContent = project.name;
|
||||||
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -15,14 +15,8 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||||||
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
Обновить цены
|
Пересчитать цену
|
||||||
</button>
|
|
||||||
<button type="button"
|
|
||||||
onclick="openPriceSettingsModal()"
|
|
||||||
class="h-10 px-3 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 inline-flex items-center justify-center"
|
|
||||||
title="Настройки цен">
|
|
||||||
Цены
|
|
||||||
</button>
|
</button>
|
||||||
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||||
Сохранить
|
Сохранить
|
||||||
@@ -40,10 +34,6 @@
|
|||||||
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||||
onchange="updateServerCount()">
|
onchange="updateServerCount()">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
<div class="font-medium text-gray-700 mb-1">Прайслисты цен</div>
|
|
||||||
<div id="pricelist-settings-summary">Estimate: авто, Склад: авто, Конкуренты: авто</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500">
|
||||||
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
|
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,37 +78,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cart summary -->
|
<!-- Cart summary -->
|
||||||
<div id="cart-summary" class="bg-white rounded-lg shadow overflow-hidden">
|
<div id="cart-summary" class="bg-white rounded-lg shadow p-4">
|
||||||
<button type="button"
|
<h3 class="font-semibold mb-3">Итого конфигурация</h3>
|
||||||
onclick="toggleCartSummarySection()"
|
<div id="cart-items" class="space-y-2 mb-4"></div>
|
||||||
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
|
<div class="border-t pt-3 flex justify-between items-center">
|
||||||
<span class="font-semibold">Итого конфигурация</span>
|
<div class="text-lg font-bold">
|
||||||
<svg id="cart-summary-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Итого: <span id="cart-total">$0.00</span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div id="cart-summary-content" class="p-4">
|
|
||||||
<div id="cart-items" class="space-y-2 mb-4"></div>
|
|
||||||
<div class="border-t pt-3 flex justify-between items-center">
|
|
||||||
<div class="text-lg font-bold">
|
|
||||||
Итого: <span id="cart-total">$0.00</span>
|
|
||||||
</div>
|
|
||||||
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom price section -->
|
<!-- Custom price section -->
|
||||||
<div id="custom-price-section" class="bg-white rounded-lg shadow overflow-hidden">
|
<div id="custom-price-section" class="bg-white rounded-lg shadow p-4">
|
||||||
<button type="button"
|
<h3 class="font-semibold mb-3">Своя цена</h3>
|
||||||
onclick="toggleCustomPriceSection()"
|
|
||||||
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
|
|
||||||
<span class="font-semibold">Своя цена</span>
|
|
||||||
<svg id="custom-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div id="custom-price-content" class="p-4">
|
|
||||||
<div class="flex items-center gap-4 mb-4">
|
<div class="flex items-center gap-4 mb-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="block text-sm text-gray-600 mb-1">Введите целевую цену</label>
|
<label class="block text-sm text-gray-600 mb-1">Введите целевую цену</label>
|
||||||
@@ -174,83 +147,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sale price section -->
|
|
||||||
<div id="sale-price-section" class="bg-white rounded-lg shadow overflow-hidden">
|
|
||||||
<button type="button"
|
|
||||||
onclick="toggleSalePriceSection()"
|
|
||||||
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
|
|
||||||
<span class="font-semibold">Цена продажи</span>
|
|
||||||
<svg id="sale-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div id="sale-price-content" class="p-4">
|
|
||||||
<div id="sale-prices" class="border-t pt-3">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
|
|
||||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во</th>
|
|
||||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Est. Price</th>
|
|
||||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Склад</th>
|
|
||||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Конкуренты</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="sale-prices-body" class="divide-y"></tbody>
|
|
||||||
<tfoot class="bg-gray-50 font-medium">
|
|
||||||
<tr>
|
|
||||||
<td class="px-3 py-2">Итого</td>
|
|
||||||
<td class="px-3 py-2 text-right">—</td>
|
|
||||||
<td class="px-3 py-2 text-right" id="sale-total-est">$0.00</td>
|
|
||||||
<td class="px-3 py-2 text-right" id="sale-total-warehouse">$0.00</td>
|
|
||||||
<td class="px-3 py-2 text-right" id="sale-total-competitor">$0.00</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Price settings modal -->
|
|
||||||
<div id="price-settings-modal" class="hidden fixed inset-0 z-50">
|
|
||||||
<div class="absolute inset-0 bg-black/40" onclick="closePriceSettingsModal()"></div>
|
|
||||||
<div class="relative max-w-xl mx-auto mt-24 bg-white rounded-lg shadow-xl border">
|
|
||||||
<div class="px-5 py-4 border-b flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Настройки цен</h3>
|
|
||||||
<button type="button" onclick="closePriceSettingsModal()" class="text-gray-500 hover:text-gray-700">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="px-5 py-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Estimate</label>
|
|
||||||
<select id="settings-pricelist-estimate" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Склад</label>
|
|
||||||
<select id="settings-pricelist-warehouse" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Конкуренты</label>
|
|
||||||
<select id="settings-pricelist-competitor" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
|
|
||||||
</div>
|
|
||||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
||||||
<input id="settings-disable-price-refresh" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
|
||||||
<span>Не обновлять цены</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="px-5 py-4 border-t flex justify-end gap-2">
|
|
||||||
<button type="button" onclick="closePriceSettingsModal()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Отмена</button>
|
|
||||||
<button type="button" onclick="applyPriceSettings()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Применить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -328,18 +224,6 @@ let cart = [];
|
|||||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||||
let serverCount = 1; // Server count for the configuration
|
let serverCount = 1; // Server count for the configuration
|
||||||
let selectedPricelistIds = {
|
|
||||||
estimate: null,
|
|
||||||
warehouse: null,
|
|
||||||
competitor: null
|
|
||||||
};
|
|
||||||
let disablePriceRefresh = false;
|
|
||||||
let activePricelistsBySource = {
|
|
||||||
estimate: [],
|
|
||||||
warehouse: [],
|
|
||||||
competitor: []
|
|
||||||
};
|
|
||||||
let priceLevelsRequestSeq = 0;
|
|
||||||
|
|
||||||
// Autocomplete state
|
// Autocomplete state
|
||||||
let autocompleteInput = null;
|
let autocompleteInput = null;
|
||||||
@@ -348,101 +232,6 @@ let autocompleteMode = null; // 'single', 'multi', 'section'
|
|||||||
let autocompleteIndex = -1;
|
let autocompleteIndex = -1;
|
||||||
let autocompleteFiltered = [];
|
let autocompleteFiltered = [];
|
||||||
|
|
||||||
function getDisplayPrice(item) {
|
|
||||||
if (typeof item.unit_price === 'number' && item.unit_price > 0) {
|
|
||||||
return item.unit_price;
|
|
||||||
}
|
|
||||||
if (typeof item.estimate_price === 'number' && item.estimate_price > 0) {
|
|
||||||
return item.estimate_price;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumberRu(value) {
|
|
||||||
const rounded = Math.round(value);
|
|
||||||
return rounded
|
|
||||||
.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
|
||||||
.replace(/[\u202f\u00a0 ]/g, '\u00A0');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMoney(value) {
|
|
||||||
return '$\u00A0' + formatNumberRu(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPriceOrNA(value) {
|
|
||||||
if (typeof value !== 'number' || value <= 0) {
|
|
||||||
return 'N/A';
|
|
||||||
}
|
|
||||||
return formatMoney(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDelta(abs, pct) {
|
|
||||||
if (typeof abs !== 'number') {
|
|
||||||
return 'N/A';
|
|
||||||
}
|
|
||||||
const sign = abs > 0 ? '+' : abs < 0 ? '-' : '';
|
|
||||||
const absValue = Math.abs(abs);
|
|
||||||
if (typeof pct !== 'number') {
|
|
||||||
return sign + formatMoney(absValue);
|
|
||||||
}
|
|
||||||
const pctSign = pct > 0 ? '+' : pct < 0 ? '-' : '';
|
|
||||||
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshPriceLevels() {
|
|
||||||
if (!configUUID || cart.length === 0 || disablePriceRefresh) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seq = ++priceLevelsRequestSeq;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
items: cart.map(item => ({
|
|
||||||
lot_name: item.lot_name,
|
|
||||||
quantity: item.quantity
|
|
||||||
})),
|
|
||||||
pricelist_ids: Object.fromEntries(
|
|
||||||
Object.entries(selectedPricelistIds)
|
|
||||||
.filter(([, id]) => typeof id === 'number' && id > 0)
|
|
||||||
)
|
|
||||||
};
|
|
||||||
const resp = await fetch('/api/quote/price-levels', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
if (seq !== priceLevelsRequestSeq) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const byLot = new Map((data.items || []).map(i => [i.lot_name, i]));
|
|
||||||
cart = cart.map(item => {
|
|
||||||
const levels = byLot.get(item.lot_name);
|
|
||||||
if (!levels) return item;
|
|
||||||
const next = { ...item, ...levels };
|
|
||||||
if (typeof levels.estimate_price === 'number' && levels.estimate_price > 0) {
|
|
||||||
next.unit_price = levels.estimate_price;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (data.resolved_pricelist_ids) {
|
|
||||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
|
||||||
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
|
|
||||||
selectedPricelistIds[source] = data.resolved_pricelist_ids[source];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
syncPriceSettingsControls();
|
|
||||||
renderPricelistSettingsSummary();
|
|
||||||
persistLocalPriceSettings();
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
console.error('Failed to refresh price levels', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load categories from API and update tab configuration
|
// Load categories from API and update tab configuration
|
||||||
async function loadCategoriesFromAPI() {
|
async function loadCategoriesFromAPI() {
|
||||||
try {
|
try {
|
||||||
@@ -507,16 +296,12 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
serverCount = config.server_count || 1;
|
serverCount = config.server_count || 1;
|
||||||
document.getElementById('server-count').value = serverCount;
|
document.getElementById('server-count').value = serverCount;
|
||||||
document.getElementById('total-server-count').textContent = serverCount;
|
document.getElementById('total-server-count').textContent = serverCount;
|
||||||
selectedPricelistIds.estimate = config.pricelist_id || null;
|
|
||||||
|
|
||||||
if (config.items && config.items.length > 0) {
|
if (config.items && config.items.length > 0) {
|
||||||
cart = config.items.map(item => ({
|
cart = config.items.map(item => ({
|
||||||
lot_name: item.lot_name,
|
lot_name: item.lot_name,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit_price: item.unit_price,
|
unit_price: item.unit_price,
|
||||||
estimate_price: item.unit_price,
|
|
||||||
warehouse_price: null,
|
|
||||||
competitor_price: null,
|
|
||||||
description: item.description || '',
|
description: item.description || '',
|
||||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||||
}));
|
}));
|
||||||
@@ -537,13 +322,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreLocalPriceSettings();
|
|
||||||
await loadActivePricelists();
|
|
||||||
syncPriceSettingsControls();
|
|
||||||
renderPricelistSettingsSummary();
|
|
||||||
updateRefreshPricesButtonState();
|
|
||||||
await loadAllComponents();
|
await loadAllComponents();
|
||||||
await refreshPriceLevels();
|
|
||||||
renderTab();
|
renderTab();
|
||||||
updateCartUI();
|
updateCartUI();
|
||||||
|
|
||||||
@@ -582,151 +361,6 @@ function updateServerCount() {
|
|||||||
triggerAutoSave();
|
triggerAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadActivePricelists() {
|
|
||||||
const sources = ['estimate', 'warehouse', 'competitor'];
|
|
||||||
await Promise.all(sources.map(async source => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
|
||||||
const data = await resp.json();
|
|
||||||
activePricelistsBySource[source] = data.pricelists || [];
|
|
||||||
const existing = selectedPricelistIds[source];
|
|
||||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
|
|
||||||
? Number(activePricelistsBySource[source][0].id)
|
|
||||||
: null;
|
|
||||||
} catch (e) {
|
|
||||||
activePricelistsBySource[source] = [];
|
|
||||||
selectedPricelistIds[source] = null;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPricelistSelectOptions(selectId, source) {
|
|
||||||
const select = document.getElementById(selectId);
|
|
||||||
if (!select) return;
|
|
||||||
const pricelists = activePricelistsBySource[source] || [];
|
|
||||||
if (pricelists.length === 0) {
|
|
||||||
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
|
|
||||||
select.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
select.innerHTML = `<option value="">Авто (последний активный)</option>` + pricelists.map(pl => {
|
|
||||||
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
|
|
||||||
}).join('');
|
|
||||||
const current = selectedPricelistIds[source];
|
|
||||||
select.value = current ? String(current) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncPriceSettingsControls() {
|
|
||||||
renderPricelistSelectOptions('settings-pricelist-estimate', 'estimate');
|
|
||||||
renderPricelistSelectOptions('settings-pricelist-warehouse', 'warehouse');
|
|
||||||
renderPricelistSelectOptions('settings-pricelist-competitor', 'competitor');
|
|
||||||
const disableCheckbox = document.getElementById('settings-disable-price-refresh');
|
|
||||||
if (disableCheckbox) {
|
|
||||||
disableCheckbox.checked = disablePriceRefresh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPricelistVersionById(source, id) {
|
|
||||||
const pricelists = activePricelistsBySource[source] || [];
|
|
||||||
const found = pricelists.find(pl => Number(pl.id) === Number(id));
|
|
||||||
return found ? found.version : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPricelistSettingsSummary() {
|
|
||||||
const summary = document.getElementById('pricelist-settings-summary');
|
|
||||||
if (!summary) return;
|
|
||||||
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : 'авто';
|
|
||||||
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
|
|
||||||
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
|
|
||||||
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
|
|
||||||
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRefreshPricesButtonState() {
|
|
||||||
const refreshBtn = document.getElementById('refresh-prices-btn');
|
|
||||||
if (!refreshBtn) return;
|
|
||||||
if (disablePriceRefresh) {
|
|
||||||
refreshBtn.disabled = true;
|
|
||||||
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
|
|
||||||
refreshBtn.title = 'Обновление цен отключено в настройках';
|
|
||||||
} else {
|
|
||||||
refreshBtn.disabled = false;
|
|
||||||
refreshBtn.className = 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700';
|
|
||||||
refreshBtn.title = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPriceSettingsStorageKey() {
|
|
||||||
return `qf_price_settings_${configUUID || 'default'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistLocalPriceSettings() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(getPriceSettingsStorageKey(), JSON.stringify({
|
|
||||||
pricelist_ids: selectedPricelistIds,
|
|
||||||
disable_price_refresh: disablePriceRefresh
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
// ignore localStorage failures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreLocalPriceSettings() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(getPriceSettingsStorageKey());
|
|
||||||
if (!raw) return;
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (parsed && parsed.pricelist_ids) {
|
|
||||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
|
||||||
const next = parseInt(parsed.pricelist_ids[source]);
|
|
||||||
if (Number.isFinite(next) && next > 0) {
|
|
||||||
selectedPricelistIds[source] = next;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
disablePriceRefresh = Boolean(parsed?.disable_price_refresh);
|
|
||||||
} catch (e) {
|
|
||||||
// ignore invalid localStorage payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openPriceSettingsModal() {
|
|
||||||
await loadActivePricelists();
|
|
||||||
syncPriceSettingsControls();
|
|
||||||
renderPricelistSettingsSummary();
|
|
||||||
document.getElementById('price-settings-modal')?.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePriceSettingsModal() {
|
|
||||||
document.getElementById('price-settings-modal')?.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPriceSettings() {
|
|
||||||
const estimateVal = parseInt(document.getElementById('settings-pricelist-estimate')?.value || '');
|
|
||||||
const warehouseVal = parseInt(document.getElementById('settings-pricelist-warehouse')?.value || '');
|
|
||||||
const competitorVal = parseInt(document.getElementById('settings-pricelist-competitor')?.value || '');
|
|
||||||
const disableVal = Boolean(document.getElementById('settings-disable-price-refresh')?.checked);
|
|
||||||
|
|
||||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
|
||||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
|
||||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
|
||||||
disablePriceRefresh = disableVal;
|
|
||||||
|
|
||||||
updateRefreshPricesButtonState();
|
|
||||||
renderPricelistSettingsSummary();
|
|
||||||
persistLocalPriceSettings();
|
|
||||||
closePriceSettingsModal();
|
|
||||||
|
|
||||||
refreshPriceLevels().then(() => {
|
|
||||||
renderTab();
|
|
||||||
updateCartUI();
|
|
||||||
triggerAutoSave();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCategoryFromLotName(lotName) {
|
function getCategoryFromLotName(lotName) {
|
||||||
const parts = lotName.split('_');
|
const parts = lotName.split('_');
|
||||||
return parts[0] || '';
|
return parts[0] || '';
|
||||||
@@ -799,7 +433,7 @@ function renderSingleSelectTab(categories) {
|
|||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-24">Тип</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-24">Тип</th>
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
|
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
|
||||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
||||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
||||||
<th class="px-3 py-2 w-10"></th>
|
<th class="px-3 py-2 w-10"></th>
|
||||||
@@ -816,9 +450,8 @@ function renderSingleSelectTab(categories) {
|
|||||||
|
|
||||||
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null;
|
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null;
|
||||||
const price = comp?.current_price || 0;
|
const price = comp?.current_price || 0;
|
||||||
const estimate = selectedItem?.estimate_price ?? price;
|
|
||||||
const qty = selectedItem?.quantity || 1;
|
const qty = selectedItem?.quantity || 1;
|
||||||
const total = (selectedItem ? getDisplayPrice(selectedItem) : price) * qty;
|
const total = price * qty;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<tr class="hover:bg-gray-50">
|
<tr class="hover:bg-gray-50">
|
||||||
@@ -836,14 +469,14 @@ function renderSingleSelectTab(categories) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs" id="desc-${cat}">${escapeHtml(comp?.description || '')}</td>
|
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs" id="desc-${cat}">${escapeHtml(comp?.description || '')}</td>
|
||||||
<td class="px-3 py-2 text-sm text-right" id="price-${cat}">${formatPriceOrNA(estimate)}</td>
|
<td class="px-3 py-2 text-sm text-right" id="price-${cat}">${price ? '$' + price.toFixed(2) : '—'}</td>
|
||||||
<td class="px-3 py-2 text-center">
|
<td class="px-3 py-2 text-center">
|
||||||
<input type="number" min="1" value="${qty}"
|
<input type="number" min="1" value="${qty}"
|
||||||
id="qty-${cat}"
|
id="qty-${cat}"
|
||||||
onchange="updateSingleQuantity('${cat}', this.value)"
|
onchange="updateSingleQuantity('${cat}', this.value)"
|
||||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-sm text-right font-medium" id="total-${cat}">${total ? formatMoney(total) : '—'}</td>
|
<td class="px-3 py-2 text-sm text-right font-medium" id="total-${cat}">${total ? '$' + total.toFixed(2) : '—'}</td>
|
||||||
<td class="px-3 py-2 text-center">
|
<td class="px-3 py-2 text-center">
|
||||||
${selectedItem ? `
|
${selectedItem ? `
|
||||||
<button onclick="clearSingleSelect('${cat}')" class="text-red-500 hover:text-red-700">
|
<button onclick="clearSingleSelect('${cat}')" class="text-red-500 hover:text-red-700">
|
||||||
@@ -875,7 +508,7 @@ function renderMultiSelectTab(components) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
|
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
|
||||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
||||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
||||||
<th class="px-3 py-2 w-10"></th>
|
<th class="px-3 py-2 w-10"></th>
|
||||||
@@ -887,19 +520,19 @@ function renderMultiSelectTab(components) {
|
|||||||
// Render existing cart items for this tab
|
// Render existing cart items for this tab
|
||||||
tabItems.forEach((item, idx) => {
|
tabItems.forEach((item, idx) => {
|
||||||
const comp = allComponents.find(c => c.lot_name === item.lot_name);
|
const comp = allComponents.find(c => c.lot_name === item.lot_name);
|
||||||
const total = getDisplayPrice(item) * item.quantity;
|
const total = item.unit_price * item.quantity;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<tr class="hover:bg-gray-50">
|
<tr class="hover:bg-gray-50">
|
||||||
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
|
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
|
||||||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
|
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
|
||||||
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
|
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td>
|
||||||
<td class="px-3 py-2 text-center">
|
<td class="px-3 py-2 text-center">
|
||||||
<input type="number" min="1" value="${item.quantity}"
|
<input type="number" min="1" value="${item.quantity}"
|
||||||
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
|
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
|
||||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
|
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td>
|
||||||
<td class="px-3 py-2 text-center">
|
<td class="px-3 py-2 text-center">
|
||||||
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -925,7 +558,7 @@ function renderMultiSelectTab(components) {
|
|||||||
onkeydown="handleAutocompleteKeyMulti(event)">
|
onkeydown="handleAutocompleteKeyMulti(event)">
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price">N/A</td>
|
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price">—</td>
|
||||||
<td class="px-3 py-2 text-center">
|
<td class="px-3 py-2 text-center">
|
||||||
<input type="number" min="1" value="1" id="new-qty"
|
<input type="number" min="1" value="1" id="new-qty"
|
||||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||||
@@ -976,7 +609,7 @@ function renderMultiSelectTabWithSections(sections) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
|
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
|
||||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
||||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
||||||
<th class="px-3 py-2 w-10"></th>
|
<th class="px-3 py-2 w-10"></th>
|
||||||
@@ -988,19 +621,19 @@ function renderMultiSelectTabWithSections(sections) {
|
|||||||
// Render existing cart items for this section
|
// Render existing cart items for this section
|
||||||
sectionItems.forEach((item) => {
|
sectionItems.forEach((item) => {
|
||||||
const comp = allComponents.find(c => c.lot_name === item.lot_name);
|
const comp = allComponents.find(c => c.lot_name === item.lot_name);
|
||||||
const total = getDisplayPrice(item) * item.quantity;
|
const total = item.unit_price * item.quantity;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<tr class="hover:bg-gray-50">
|
<tr class="hover:bg-gray-50">
|
||||||
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
|
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
|
||||||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
|
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
|
||||||
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
|
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td>
|
||||||
<td class="px-3 py-2 text-center">
|
<td class="px-3 py-2 text-center">
|
||||||
<input type="number" min="1" value="${item.quantity}"
|
<input type="number" min="1" value="${item.quantity}"
|
||||||
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
|
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
|
||||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
|
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td>
|
||||||
<td class="px-3 py-2 text-center">
|
<td class="px-3 py-2 text-center">
|
||||||
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -1029,7 +662,7 @@ function renderMultiSelectTabWithSections(sections) {
|
|||||||
onkeydown="handleAutocompleteKeySection(event, '${sectionId}')">
|
onkeydown="handleAutocompleteKeySection(event, '${sectionId}')">
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}">N/A</td>
|
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}">—</td>
|
||||||
<td class="px-3 py-2 text-center">
|
<td class="px-3 py-2 text-center">
|
||||||
<input type="number" min="1" value="1" id="new-qty-${sectionId}"
|
<input type="number" min="1" value="1" id="new-qty-${sectionId}"
|
||||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||||
@@ -1151,26 +784,14 @@ function selectAutocompleteItem(index) {
|
|||||||
lot_name: comp.lot_name,
|
lot_name: comp.lot_name,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_price: comp.current_price,
|
unit_price: comp.current_price,
|
||||||
estimate_price: comp.current_price,
|
|
||||||
warehouse_price: null,
|
|
||||||
competitor_price: null,
|
|
||||||
delta_wh_estimate_abs: null,
|
|
||||||
delta_wh_estimate_pct: null,
|
|
||||||
delta_comp_estimate_abs: null,
|
|
||||||
delta_comp_estimate_pct: null,
|
|
||||||
delta_comp_wh_abs: null,
|
|
||||||
delta_comp_wh_pct: null,
|
|
||||||
price_missing: ['warehouse', 'competitor'],
|
|
||||||
description: comp.description || '',
|
description: comp.description || '',
|
||||||
category: getComponentCategory(comp)
|
category: getComponentCategory(comp)
|
||||||
});
|
});
|
||||||
|
|
||||||
hideAutocomplete();
|
hideAutocomplete();
|
||||||
refreshPriceLevels().then(() => {
|
renderTab();
|
||||||
renderTab();
|
updateCartUI();
|
||||||
updateCartUI();
|
triggerAutoSave();
|
||||||
triggerAutoSave();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideAutocomplete() {
|
function hideAutocomplete() {
|
||||||
@@ -1243,26 +864,14 @@ function selectAutocompleteItemMulti(index) {
|
|||||||
lot_name: comp.lot_name,
|
lot_name: comp.lot_name,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_price: comp.current_price,
|
unit_price: comp.current_price,
|
||||||
estimate_price: comp.current_price,
|
|
||||||
warehouse_price: null,
|
|
||||||
competitor_price: null,
|
|
||||||
delta_wh_estimate_abs: null,
|
|
||||||
delta_wh_estimate_pct: null,
|
|
||||||
delta_comp_estimate_abs: null,
|
|
||||||
delta_comp_estimate_pct: null,
|
|
||||||
delta_comp_wh_abs: null,
|
|
||||||
delta_comp_wh_pct: null,
|
|
||||||
price_missing: ['warehouse', 'competitor'],
|
|
||||||
description: comp.description || '',
|
description: comp.description || '',
|
||||||
category: getComponentCategory(comp)
|
category: getComponentCategory(comp)
|
||||||
});
|
});
|
||||||
|
|
||||||
hideAutocomplete();
|
hideAutocomplete();
|
||||||
refreshPriceLevels().then(() => {
|
renderTab();
|
||||||
renderTab();
|
updateCartUI();
|
||||||
updateCartUI();
|
triggerAutoSave();
|
||||||
triggerAutoSave();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
||||||
@@ -1342,16 +951,6 @@ function selectAutocompleteItemSection(index, sectionId) {
|
|||||||
lot_name: comp.lot_name,
|
lot_name: comp.lot_name,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_price: comp.current_price,
|
unit_price: comp.current_price,
|
||||||
estimate_price: comp.current_price,
|
|
||||||
warehouse_price: null,
|
|
||||||
competitor_price: null,
|
|
||||||
delta_wh_estimate_abs: null,
|
|
||||||
delta_wh_estimate_pct: null,
|
|
||||||
delta_comp_estimate_abs: null,
|
|
||||||
delta_comp_estimate_pct: null,
|
|
||||||
delta_comp_wh_abs: null,
|
|
||||||
delta_comp_wh_pct: null,
|
|
||||||
price_missing: ['warehouse', 'competitor'],
|
|
||||||
description: comp.description || '',
|
description: comp.description || '',
|
||||||
category: getComponentCategory(comp)
|
category: getComponentCategory(comp)
|
||||||
});
|
});
|
||||||
@@ -1365,11 +964,9 @@ function selectAutocompleteItemSection(index, sectionId) {
|
|||||||
// Reset quantity to 1
|
// Reset quantity to 1
|
||||||
if (qtyInput) qtyInput.value = '1';
|
if (qtyInput) qtyInput.value = '1';
|
||||||
|
|
||||||
refreshPriceLevels().then(() => {
|
renderTab();
|
||||||
renderTab();
|
updateCartUI();
|
||||||
updateCartUI();
|
triggerAutoSave();
|
||||||
triggerAutoSave();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSingleSelect(category) {
|
function clearSingleSelect(category) {
|
||||||
@@ -1408,7 +1005,7 @@ function updateMultiQuantity(lotName, value) {
|
|||||||
if (row) {
|
if (row) {
|
||||||
const totalCell = row.querySelector('td:nth-child(5)');
|
const totalCell = row.querySelector('td:nth-child(5)');
|
||||||
if (totalCell) {
|
if (totalCell) {
|
||||||
totalCell.textContent = formatMoney(getDisplayPrice(item) * item.quantity);
|
totalCell.textContent = '$' + (item.unit_price * item.quantity).toFixed(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1422,12 +1019,11 @@ function removeFromCart(lotName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateCartUI() {
|
function updateCartUI() {
|
||||||
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
const total = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||||
document.getElementById('cart-total').textContent = formatMoney(total);
|
document.getElementById('cart-total').textContent = '$' + total.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||||
|
|
||||||
// Recalculate custom price section if active
|
// Recalculate custom price section if active
|
||||||
calculateCustomPrice();
|
calculateCustomPrice();
|
||||||
renderSalePriceTable();
|
|
||||||
|
|
||||||
if (cart.length === 0) {
|
if (cart.length === 0) {
|
||||||
document.getElementById('cart-items').innerHTML =
|
document.getElementById('cart-items').innerHTML =
|
||||||
@@ -1471,7 +1067,7 @@ function updateCartUI() {
|
|||||||
html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`;
|
html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`;
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const itemTotal = getDisplayPrice(item) * item.quantity;
|
const itemTotal = item.unit_price * item.quantity;
|
||||||
html += `
|
html += `
|
||||||
<div class="flex justify-between items-center py-1 text-sm">
|
<div class="flex justify-between items-center py-1 text-sm">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -1480,7 +1076,7 @@ function updateCartUI() {
|
|||||||
<span>${item.quantity}</span>
|
<span>${item.quantity}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span>${formatMoney(itemTotal)}</span>
|
<span>$${itemTotal.toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
|
||||||
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
@@ -1537,8 +1133,7 @@ async function saveConfig(showNotification = true) {
|
|||||||
items: cart,
|
items: cart,
|
||||||
custom_price: customPrice,
|
custom_price: customPrice,
|
||||||
notes: '',
|
notes: '',
|
||||||
server_count: serverCountValue,
|
server_count: serverCountValue
|
||||||
pricelist_id: selectedPricelistIds.estimate
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1581,143 +1176,12 @@ async function exportCSV() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLineTotalTooltip(qty, unitPrice) {
|
|
||||||
if (typeof unitPrice !== 'number' || unitPrice <= 0) return '';
|
|
||||||
const lineTotal = qty * unitPrice;
|
|
||||||
return `${formatNumberRu(qty)} * ${formatMoney(unitPrice)} = ${formatMoney(lineTotal)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSection(contentId, iconId) {
|
|
||||||
const content = document.getElementById(contentId);
|
|
||||||
const icon = document.getElementById(iconId);
|
|
||||||
if (!content || !icon) return;
|
|
||||||
|
|
||||||
const isHidden = content.classList.toggle('hidden');
|
|
||||||
if (isHidden) {
|
|
||||||
icon.classList.add('-rotate-90');
|
|
||||||
} else {
|
|
||||||
icon.classList.remove('-rotate-90');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCartSummarySection() {
|
|
||||||
toggleSection('cart-summary-content', 'cart-summary-toggle-icon');
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCustomPriceSection() {
|
|
||||||
toggleSection('custom-price-content', 'custom-price-toggle-icon');
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSalePriceSection() {
|
|
||||||
toggleSection('sale-price-content', 'sale-price-toggle-icon');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDiffPercent(baseTotal, compareTotal, compareLabel) {
|
|
||||||
if (typeof baseTotal !== 'number' || typeof compareTotal !== 'number' || compareTotal <= 0) {
|
|
||||||
return `N/A от ${compareLabel}`;
|
|
||||||
}
|
|
||||||
const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
|
|
||||||
const sign = pct > 0 ? '+' : '';
|
|
||||||
return `${sign}${pct.toFixed(1)}% от ${compareLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTotalClass(current, references) {
|
|
||||||
const validRefs = references.filter(v => typeof v === 'number' && v > 0);
|
|
||||||
if (typeof current !== 'number' || current <= 0 || validRefs.length === 0) {
|
|
||||||
return 'text-gray-900';
|
|
||||||
}
|
|
||||||
const avg = validRefs.reduce((sum, v) => sum + v, 0) / validRefs.length;
|
|
||||||
if (current < avg) return 'text-green-600';
|
|
||||||
if (current > avg) return 'text-red-600';
|
|
||||||
return 'text-gray-900';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSalePriceTable() {
|
|
||||||
const body = document.getElementById('sale-prices-body');
|
|
||||||
const totalEstEl = document.getElementById('sale-total-est');
|
|
||||||
const totalWarehouseEl = document.getElementById('sale-total-warehouse');
|
|
||||||
const totalCompetitorEl = document.getElementById('sale-total-competitor');
|
|
||||||
if (!body || !totalEstEl || !totalWarehouseEl || !totalCompetitorEl) return;
|
|
||||||
|
|
||||||
if (cart.length === 0) {
|
|
||||||
body.innerHTML = '<tr><td colspan="5" class="px-3 py-3 text-center text-gray-500">Конфигурация пуста</td></tr>';
|
|
||||||
totalEstEl.textContent = '$0.00';
|
|
||||||
totalWarehouseEl.textContent = '$0.00';
|
|
||||||
totalCompetitorEl.textContent = '$0.00';
|
|
||||||
totalEstEl.title = '';
|
|
||||||
totalWarehouseEl.title = '';
|
|
||||||
totalCompetitorEl.title = '';
|
|
||||||
totalEstEl.className = 'px-3 py-2 text-right';
|
|
||||||
totalWarehouseEl.className = 'px-3 py-2 text-right';
|
|
||||||
totalCompetitorEl.className = 'px-3 py-2 text-right';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedCart = [...cart].sort((a, b) => {
|
|
||||||
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
|
|
||||||
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
|
|
||||||
const orderA = categoryOrderMap[catA] || 9999;
|
|
||||||
const orderB = categoryOrderMap[catB] || 9999;
|
|
||||||
return orderA - orderB;
|
|
||||||
});
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
let totalEstimate = 0;
|
|
||||||
let totalWarehouse = 0;
|
|
||||||
let totalCompetitor = 0;
|
|
||||||
|
|
||||||
sortedCart.forEach(item => {
|
|
||||||
const qty = item.quantity || 1;
|
|
||||||
const estPrice = item.estimate_price;
|
|
||||||
const warehousePrice = item.warehouse_price;
|
|
||||||
const competitorPrice = item.competitor_price;
|
|
||||||
|
|
||||||
if (typeof estPrice === 'number' && estPrice > 0) totalEstimate += estPrice * qty;
|
|
||||||
if (typeof warehousePrice === 'number' && warehousePrice > 0) totalWarehouse += warehousePrice * qty;
|
|
||||||
if (typeof competitorPrice === 'number' && competitorPrice > 0) totalCompetitor += competitorPrice * qty;
|
|
||||||
|
|
||||||
const estTooltip = formatLineTotalTooltip(qty, estPrice);
|
|
||||||
const warehouseTooltip = formatLineTotalTooltip(qty, warehousePrice);
|
|
||||||
const competitorTooltip = formatLineTotalTooltip(qty, competitorPrice);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<tr>
|
|
||||||
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
|
|
||||||
<td class="px-3 py-2 text-right">${qty}</td>
|
|
||||||
<td class="px-3 py-2 text-right" title="${escapeHtml(estTooltip)}">${formatPriceOrNA(estPrice)}</td>
|
|
||||||
<td class="px-3 py-2 text-right" title="${escapeHtml(warehouseTooltip)}">${formatPriceOrNA(warehousePrice)}</td>
|
|
||||||
<td class="px-3 py-2 text-right" title="${escapeHtml(competitorTooltip)}">${formatPriceOrNA(competitorPrice)}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
body.innerHTML = html;
|
|
||||||
|
|
||||||
totalEstEl.textContent = formatMoney(totalEstimate);
|
|
||||||
totalWarehouseEl.textContent = formatMoney(totalWarehouse);
|
|
||||||
totalCompetitorEl.textContent = formatMoney(totalCompetitor);
|
|
||||||
|
|
||||||
totalEstEl.title =
|
|
||||||
`${formatDiffPercent(totalEstimate, totalWarehouse, 'склад')}\n` +
|
|
||||||
`${formatDiffPercent(totalEstimate, totalCompetitor, 'конкуренты')}`;
|
|
||||||
totalWarehouseEl.title =
|
|
||||||
`${formatDiffPercent(totalWarehouse, totalEstimate, 'est.price')}\n` +
|
|
||||||
`${formatDiffPercent(totalWarehouse, totalCompetitor, 'конкуренты')}`;
|
|
||||||
totalCompetitorEl.title =
|
|
||||||
`${formatDiffPercent(totalCompetitor, totalEstimate, 'est.price')}\n` +
|
|
||||||
`${formatDiffPercent(totalCompetitor, totalWarehouse, 'склад')}`;
|
|
||||||
|
|
||||||
totalEstEl.className = `px-3 py-2 text-right ${getTotalClass(totalEstimate, [totalWarehouse, totalCompetitor])}`;
|
|
||||||
totalWarehouseEl.className = `px-3 py-2 text-right ${getTotalClass(totalWarehouse, [totalEstimate, totalCompetitor])}`;
|
|
||||||
totalCompetitorEl.className = `px-3 py-2 text-right ${getTotalClass(totalCompetitor, [totalEstimate, totalWarehouse])}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom price functionality
|
// Custom price functionality
|
||||||
function calculateCustomPrice() {
|
function calculateCustomPrice() {
|
||||||
const customPriceInput = document.getElementById('custom-price-input');
|
const customPriceInput = document.getElementById('custom-price-input');
|
||||||
const customPrice = parseFloat(customPriceInput.value) || 0;
|
const customPrice = parseFloat(customPriceInput.value) || 0;
|
||||||
|
|
||||||
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||||
|
|
||||||
if (customPrice <= 0 || cart.length === 0 || originalTotal <= 0) {
|
if (customPrice <= 0 || cart.length === 0 || originalTotal <= 0) {
|
||||||
document.getElementById('adjusted-prices').classList.add('hidden');
|
document.getElementById('adjusted-prices').classList.add('hidden');
|
||||||
@@ -1758,7 +1222,7 @@ function calculateCustomPrice() {
|
|||||||
let totalNew = 0;
|
let totalNew = 0;
|
||||||
|
|
||||||
sortedCart.forEach(item => {
|
sortedCart.forEach(item => {
|
||||||
const originalPrice = getDisplayPrice(item);
|
const originalPrice = item.unit_price;
|
||||||
const newPrice = originalPrice * coefficient;
|
const newPrice = originalPrice * coefficient;
|
||||||
const itemOriginalTotal = originalPrice * item.quantity;
|
const itemOriginalTotal = originalPrice * item.quantity;
|
||||||
const itemNewTotal = newPrice * item.quantity;
|
const itemNewTotal = newPrice * item.quantity;
|
||||||
@@ -1770,17 +1234,17 @@ function calculateCustomPrice() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
|
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
|
||||||
<td class="px-3 py-2 text-right">${item.quantity}</td>
|
<td class="px-3 py-2 text-right">${item.quantity}</td>
|
||||||
<td class="px-3 py-2 text-right text-gray-500">${formatMoney(originalPrice)}</td>
|
<td class="px-3 py-2 text-right text-gray-500">$${originalPrice.toFixed(2)}</td>
|
||||||
<td class="px-3 py-2 text-right text-green-600">${formatMoney(newPrice)}</td>
|
<td class="px-3 py-2 text-right text-green-600">$${newPrice.toFixed(2)}</td>
|
||||||
<td class="px-3 py-2 text-right">${formatMoney(itemNewTotal)}</td>
|
<td class="px-3 py-2 text-right">$${itemNewTotal.toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('adjusted-prices-body').innerHTML = html;
|
document.getElementById('adjusted-prices-body').innerHTML = html;
|
||||||
document.getElementById('adjusted-total-original').textContent = formatMoney(totalOriginal);
|
document.getElementById('adjusted-total-original').textContent = '$' + totalOriginal.toFixed(2);
|
||||||
document.getElementById('adjusted-total-new').textContent = formatMoney(totalNew);
|
document.getElementById('adjusted-total-new').textContent = '$' + totalNew.toFixed(2);
|
||||||
document.getElementById('adjusted-total-final').textContent = formatMoney(totalNew);
|
document.getElementById('adjusted-total-final').textContent = '$' + totalNew.toFixed(2);
|
||||||
document.getElementById('adjusted-prices').classList.remove('hidden');
|
document.getElementById('adjusted-prices').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1795,7 +1259,7 @@ async function exportCSVWithCustomPrice() {
|
|||||||
if (cart.length === 0) return;
|
if (cart.length === 0) return;
|
||||||
|
|
||||||
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
|
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
|
||||||
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||||
|
|
||||||
if (customPrice <= 0 || originalTotal <= 0) {
|
if (customPrice <= 0 || originalTotal <= 0) {
|
||||||
showToast('Введите целевую цену', 'error');
|
showToast('Введите целевую цену', 'error');
|
||||||
@@ -1807,7 +1271,7 @@ async function exportCSVWithCustomPrice() {
|
|||||||
// Create adjusted cart
|
// Create adjusted cart
|
||||||
const adjustedCart = cart.map(item => ({
|
const adjustedCart = cart.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
unit_price: parseFloat((getDisplayPrice(item) * coefficient).toFixed(2))
|
unit_price: parseFloat((item.unit_price * coefficient).toFixed(2))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1832,10 +1296,6 @@ async function exportCSVWithCustomPrice() {
|
|||||||
async function refreshPrices() {
|
async function refreshPrices() {
|
||||||
// RBAC disabled - no token check required
|
// RBAC disabled - no token check required
|
||||||
if (!configUUID) return;
|
if (!configUUID) return;
|
||||||
if (disablePriceRefresh) {
|
|
||||||
showToast('Обновление цен отключено в настройках', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
||||||
@@ -1858,9 +1318,6 @@ async function refreshPrices() {
|
|||||||
lot_name: item.lot_name,
|
lot_name: item.lot_name,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit_price: item.unit_price,
|
unit_price: item.unit_price,
|
||||||
estimate_price: item.unit_price,
|
|
||||||
warehouse_price: null,
|
|
||||||
competitor_price: null,
|
|
||||||
description: item.description || '',
|
description: item.description || '',
|
||||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||||
}));
|
}));
|
||||||
@@ -1870,18 +1327,8 @@ async function refreshPrices() {
|
|||||||
if (config.price_updated_at) {
|
if (config.price_updated_at) {
|
||||||
updatePriceUpdateDate(config.price_updated_at);
|
updatePriceUpdateDate(config.price_updated_at);
|
||||||
}
|
}
|
||||||
if (config.pricelist_id) {
|
|
||||||
selectedPricelistIds.estimate = config.pricelist_id;
|
|
||||||
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
|
||||||
await loadActivePricelists();
|
|
||||||
}
|
|
||||||
syncPriceSettingsControls();
|
|
||||||
renderPricelistSettingsSummary();
|
|
||||||
persistLocalPriceSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-render UI
|
// Re-render UI
|
||||||
await refreshPriceLevels();
|
|
||||||
renderTab();
|
renderTab();
|
||||||
updateCartUI();
|
updateCartUI();
|
||||||
|
|
||||||
|
|||||||
@@ -147,15 +147,12 @@
|
|||||||
let settings = [];
|
let settings = [];
|
||||||
const hasManualPrice = item.manual_price && item.manual_price > 0;
|
const hasManualPrice = item.manual_price && item.manual_price > 0;
|
||||||
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
|
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
|
||||||
const method = (item.price_method || '').toLowerCase();
|
|
||||||
|
|
||||||
// Method indicator
|
// Method indicator
|
||||||
if (hasManualPrice) {
|
if (hasManualPrice) {
|
||||||
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
|
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
|
||||||
} else if (method === 'average') {
|
} else if (item.price_method === 'average') {
|
||||||
settings.push('Сред');
|
settings.push('Сред');
|
||||||
} else if (method === 'weighted_median') {
|
|
||||||
settings.push('Взвеш. мед');
|
|
||||||
} else {
|
} else {
|
||||||
settings.push('Мед');
|
settings.push('Мед');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,6 @@
|
|||||||
Импорт квоты
|
Импорт квоты
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
|
||||||
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
|
||||||
открыть в трекере
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
|
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
|
||||||
<button id="status-active-btn" onclick="setConfigStatusMode('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">
|
<button id="status-active-btn" onclick="setConfigStatusMode('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||||
@@ -125,12 +120,6 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProjectTrackerURL(projectData) {
|
|
||||||
if (!projectData) return '';
|
|
||||||
const explicitURL = (projectData.tracker_url || '').trim();
|
|
||||||
return explicitURL;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setConfigStatusMode(mode) {
|
function setConfigStatusMode(mode) {
|
||||||
if (mode !== 'active' && mode !== 'archived') return;
|
if (mode !== 'active' && mode !== 'archived') return;
|
||||||
configStatusMode = mode;
|
configStatusMode = mode;
|
||||||
@@ -229,20 +218,6 @@ async function loadProject() {
|
|||||||
}
|
}
|
||||||
project = await resp.json();
|
project = await resp.json();
|
||||||
document.getElementById('project-title').textContent = project.name;
|
document.getElementById('project-title').textContent = project.name;
|
||||||
const trackerLink = document.getElementById('tracker-link');
|
|
||||||
if (trackerLink) {
|
|
||||||
if (project && project.is_system) {
|
|
||||||
trackerLink.classList.add('hidden');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const trackerURL = resolveProjectTrackerURL(project);
|
|
||||||
if (trackerURL) {
|
|
||||||
trackerLink.href = trackerURL;
|
|
||||||
trackerLink.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
trackerLink.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<a href="/configs" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
|
<a href="/configs" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
|
||||||
Все конфигурации
|
Все конфигурации
|
||||||
</a>
|
</a>
|
||||||
<button onclick="openCreateProjectModal()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
<button onclick="createProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
+ Новый проект
|
+ Новый проект
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,40 +27,9 @@
|
|||||||
<div id="projects-table" class="bg-white rounded-lg shadow p-4 text-gray-500">Загрузка...</div>
|
<div id="projects-table" class="bg-white rounded-lg shadow p-4 text-gray-500">Загрузка...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="create-project-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
|
||||||
<h2 class="text-xl font-semibold mb-4">Новый проект</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
|
||||||
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
|
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
|
|
||||||
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
|
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-2 mt-6">
|
|
||||||
<button type="button" onclick="closeCreateProjectModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
|
||||||
<button type="button" onclick="createProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let status = 'active';
|
let status = 'active';
|
||||||
let projectsSearch = '';
|
let projectsSearch = '';
|
||||||
let authorSearch = '';
|
|
||||||
let currentPage = 1;
|
|
||||||
let perPage = 10;
|
|
||||||
let sortField = 'created_at';
|
|
||||||
let sortDir = 'desc';
|
|
||||||
let createProjectTrackerManuallyEdited = false;
|
|
||||||
let createProjectLastAutoTrackerURL = '';
|
|
||||||
|
|
||||||
const trackerBaseURL = 'https://tracker.yandex.ru/';
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -72,33 +41,8 @@ function formatMoney(v) {
|
|||||||
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
|
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) {
|
function setStatus(value) {
|
||||||
status = value;
|
status = value;
|
||||||
currentPage = 1;
|
|
||||||
document.getElementById('status-active-btn').className = value === 'active'
|
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-blue-600 text-white'
|
||||||
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
|
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
|
||||||
@@ -113,73 +57,36 @@ async function loadProjects() {
|
|||||||
root.innerHTML = '<div class="text-gray-500">Загрузка...</div>';
|
root.innerHTML = '<div class="text-gray-500">Загрузка...</div>';
|
||||||
|
|
||||||
let rows = [];
|
let rows = [];
|
||||||
let total = 0;
|
|
||||||
let totalPages = 0;
|
|
||||||
let page = currentPage;
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const resp = await fetch('/api/projects?status=' + status + '&search=' + encodeURIComponent(projectsSearch));
|
||||||
status: status,
|
|
||||||
search: projectsSearch,
|
|
||||||
author: authorSearch,
|
|
||||||
page: String(currentPage),
|
|
||||||
per_page: String(perPage),
|
|
||||||
sort: sortField,
|
|
||||||
dir: sortDir
|
|
||||||
});
|
|
||||||
const resp = await fetch('/api/projects?' + params.toString());
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error('HTTP ' + resp.status);
|
throw new Error('HTTP ' + resp.status);
|
||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
rows = data.projects || [];
|
rows = data.projects || [];
|
||||||
total = data.total || 0;
|
|
||||||
totalPages = data.total_pages || 0;
|
|
||||||
page = data.page || currentPage;
|
|
||||||
currentPage = page;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
|
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
root.innerHTML = '<div class="text-gray-500">Проектов нет</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
||||||
html += '<thead class="bg-gray-50">';
|
html += '<thead class="bg-gray-50"><tr>';
|
||||||
html += '<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">';
|
|
||||||
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта';
|
|
||||||
if (sortField === 'name') {
|
|
||||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
|
||||||
}
|
|
||||||
html += '</button></th>';
|
|
||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</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 += '<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></thead><tbody class="divide-y">';
|
||||||
html += '<tr>';
|
|
||||||
html += '<th class="px-4 py-2"></th>';
|
|
||||||
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
|
|
||||||
html += '<th class="px-4 py-2"></th>';
|
|
||||||
html += '<th class="px-4 py-2"></th>';
|
|
||||||
html += '<th class="px-4 py-2"></th>';
|
|
||||||
html += '<th class="px-4 py-2"></th>';
|
|
||||||
html += '</tr>';
|
|
||||||
html += '</thead><tbody class="divide-y">';
|
|
||||||
|
|
||||||
if (!rows.length) {
|
|
||||||
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.forEach(p => {
|
rows.forEach(p => {
|
||||||
html += '<tr class="hover:bg-gray-50">';
|
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 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(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">' + (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 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">';
|
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
||||||
@@ -210,94 +117,21 @@ async function loadProjects() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
html += '</tbody></table></div>';
|
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;
|
root.innerHTML = html;
|
||||||
|
|
||||||
const authorInput = document.getElementById('projects-author-filter');
|
|
||||||
if (authorInput) {
|
|
||||||
authorInput.addEventListener('input', function(e) {
|
|
||||||
authorSearch = (e.target.value || '').trim();
|
|
||||||
currentPage = 1;
|
|
||||||
loadProjects();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToPage(page) {
|
|
||||||
if (page < 1) return;
|
|
||||||
currentPage = page;
|
|
||||||
loadProjects();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTrackerURLFromProjectCode(projectCode) {
|
|
||||||
const code = (projectCode || '').trim();
|
|
||||||
if (!code) return '';
|
|
||||||
return trackerBaseURL + encodeURIComponent(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateProjectModal() {
|
|
||||||
const codeInput = document.getElementById('create-project-code');
|
|
||||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
|
||||||
codeInput.value = '';
|
|
||||||
trackerInput.value = '';
|
|
||||||
createProjectTrackerManuallyEdited = false;
|
|
||||||
createProjectLastAutoTrackerURL = '';
|
|
||||||
document.getElementById('create-project-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('create-project-modal').classList.add('flex');
|
|
||||||
codeInput.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCreateProjectModal() {
|
|
||||||
document.getElementById('create-project-modal').classList.add('hidden');
|
|
||||||
document.getElementById('create-project-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCreateProjectTrackerURL() {
|
|
||||||
const codeInput = document.getElementById('create-project-code');
|
|
||||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
|
||||||
const generatedURL = buildTrackerURLFromProjectCode(codeInput.value);
|
|
||||||
if (!createProjectTrackerManuallyEdited || trackerInput.value.trim() === '' || trackerInput.value === createProjectLastAutoTrackerURL) {
|
|
||||||
trackerInput.value = generatedURL;
|
|
||||||
createProjectLastAutoTrackerURL = generatedURL;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createProject() {
|
async function createProject() {
|
||||||
const codeInput = document.getElementById('create-project-code');
|
const name = prompt('Название проекта');
|
||||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
if (!name || !name.trim()) return;
|
||||||
const name = (codeInput.value || '').trim();
|
|
||||||
if (!name) {
|
|
||||||
alert('Введите код проекта');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resp = await fetch('/api/projects', {
|
const resp = await fetch('/api/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({name: name.trim()})
|
||||||
name: name,
|
|
||||||
tracker_url: (trackerInput.value || '').trim()
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
alert('Не удалось создать проект');
|
alert('Не удалось создать проект');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeCreateProjectModal();
|
|
||||||
loadProjects();
|
loadProjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,37 +223,8 @@ loadProjects();
|
|||||||
|
|
||||||
document.getElementById('projects-search').addEventListener('input', function(e) {
|
document.getElementById('projects-search').addEventListener('input', function(e) {
|
||||||
projectsSearch = (e.target.value || '').trim();
|
projectsSearch = (e.target.value || '').trim();
|
||||||
currentPage = 1;
|
|
||||||
loadProjects();
|
loadProjects();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('create-project-code').addEventListener('input', function() {
|
|
||||||
updateCreateProjectTrackerURL();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
|
|
||||||
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('create-project-code').addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
createProject();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
createProject();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('create-project-modal').addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closeCreateProjectModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user