Compare commits
10 Commits
v1.5.9
...
e9307c4bad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9307c4bad | ||
|
|
1b48401828 | ||
|
|
4a86f7b7ba | ||
|
|
955467fbea | ||
|
|
9ddffe48e9 | ||
|
|
4732605925 | ||
|
|
d318a7f462 | ||
|
|
1bec110d91 | ||
|
|
6392e4b4a9 | ||
|
|
8f7defdb8a |
54
README.md
54
README.md
@@ -85,6 +85,60 @@ auth:
|
||||
go run ./cmd/qfs -migrate
|
||||
```
|
||||
|
||||
### Мигратор OPS -> проекты (preview/apply)
|
||||
|
||||
Переносит квоты, чьи названия начинаются с `OPS-xxxx` (где `x` — цифра), в проект `OPS-xxxx`.
|
||||
Если проекта нет, он будет создан; если архивный — реактивирован.
|
||||
|
||||
Сначала всегда смотрите preview:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml
|
||||
```
|
||||
|
||||
Применение изменений:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml -apply
|
||||
```
|
||||
|
||||
Без интерактивного подтверждения:
|
||||
|
||||
```bash
|
||||
go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
|
||||
```
|
||||
|
||||
### Минимальные права БД для пользователя квотаций
|
||||
|
||||
Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты:
|
||||
|
||||
```sql
|
||||
-- 1) Создать (или оставить существующего) пользователя
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||
|
||||
-- 2) Сбросить лишние права (без пересоздания пользователя)
|
||||
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%';
|
||||
|
||||
-- 3) Чтение данных для конфигуратора и синка
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
|
||||
|
||||
-- 4) Работа с конфигурациями
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
```
|
||||
|
||||
Важно:
|
||||
- не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами;
|
||||
- если используется host-специфичный аккаунт (`'quote_user'@'192.168.x.x'`), назначьте права и для него;
|
||||
- после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя.
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
|
||||
```bash
|
||||
|
||||
@@ -81,4 +81,4 @@ func main() {
|
||||
log.Println(" - reset-counters: Reset usage counters")
|
||||
log.Println(" - update-popularity: Update popularity scores")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func main() {
|
||||
|
||||
// Get all configurations from MariaDB
|
||||
var configs []models.Configuration
|
||||
if err := mariaDB.Preload("User").Find(&configs).Error; err != nil {
|
||||
if err := mariaDB.Find(&configs).Error; err != nil {
|
||||
log.Fatalf("Failed to fetch configurations: %v", err)
|
||||
}
|
||||
|
||||
@@ -78,9 +78,6 @@ func main() {
|
||||
log.Println("\n[DRY RUN] Would migrate the following configurations:")
|
||||
for _, c := range configs {
|
||||
userName := c.OwnerUsername
|
||||
if userName == "" && c.User != nil {
|
||||
userName = c.User.Username
|
||||
}
|
||||
if userName == "" {
|
||||
userName = "unknown"
|
||||
}
|
||||
@@ -120,6 +117,7 @@ func main() {
|
||||
localConfig := &localdb.LocalConfiguration{
|
||||
UUID: c.UUID,
|
||||
ServerID: &c.ID,
|
||||
ProjectUUID: c.ProjectUUID,
|
||||
Name: c.Name,
|
||||
Items: localItems,
|
||||
TotalPrice: c.TotalPrice,
|
||||
@@ -131,14 +129,10 @@ func main() {
|
||||
UpdatedAt: now,
|
||||
SyncedAt: &now,
|
||||
SyncStatus: "synced",
|
||||
OriginalUserID: c.UserID,
|
||||
OriginalUserID: derefUint(c.UserID),
|
||||
OriginalUsername: c.OwnerUsername,
|
||||
}
|
||||
|
||||
if localConfig.OriginalUsername == "" && c.User != nil {
|
||||
localConfig.OriginalUsername = c.User.Username
|
||||
}
|
||||
|
||||
if err := local.SaveConfiguration(localConfig); err != nil {
|
||||
log.Printf(" ERROR: %s - %v", c.Name, err)
|
||||
errors++
|
||||
@@ -173,3 +167,10 @@ func main() {
|
||||
|
||||
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server")
|
||||
}
|
||||
|
||||
func derefUint(v *uint) uint {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
283
cmd/migrate_ops_projects/main.go
Normal file
283
cmd/migrate_ops_projects/main.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type configRow struct {
|
||||
ID uint
|
||||
UUID string
|
||||
OwnerUsername string
|
||||
Name string
|
||||
ProjectUUID *string
|
||||
}
|
||||
|
||||
type migrationAction struct {
|
||||
ConfigID uint
|
||||
ConfigUUID string
|
||||
ConfigName string
|
||||
OwnerUsername string
|
||||
TargetProjectName string
|
||||
CurrentProject string
|
||||
NeedCreateProject bool
|
||||
NeedReactivate bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||
apply := flag.Bool("apply", false, "apply migration (default is preview only)")
|
||||
yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect database: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureProjectsTable(db); err != nil {
|
||||
log.Fatalf("precheck failed: %v", err)
|
||||
}
|
||||
|
||||
actions, existingProjects, err := buildPlan(db, cfg.Database.User)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to build migration plan: %v", err)
|
||||
}
|
||||
|
||||
printPlan(actions)
|
||||
if len(actions) == 0 {
|
||||
fmt.Println("Nothing to migrate.")
|
||||
return
|
||||
}
|
||||
|
||||
if !*apply {
|
||||
fmt.Println("\nPreview complete. Re-run with -apply to execute.")
|
||||
return
|
||||
}
|
||||
|
||||
if !*yes {
|
||||
ok, confirmErr := askForConfirmation()
|
||||
if confirmErr != nil {
|
||||
log.Fatalf("confirmation failed: %v", confirmErr)
|
||||
}
|
||||
if !ok {
|
||||
fmt.Println("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := executePlan(db, actions, existingProjects); err != nil {
|
||||
log.Fatalf("migration failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Migration completed successfully.")
|
||||
}
|
||||
|
||||
func ensureProjectsTable(db *gorm.DB) error {
|
||||
var count int64
|
||||
if err := db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'qt_projects'").Scan(&count).Error; err != nil {
|
||||
return fmt.Errorf("checking qt_projects table: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
return fmt.Errorf("table qt_projects does not exist; run migration 009_add_projects.sql first")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string]*models.Project, error) {
|
||||
var configs []configRow
|
||||
if err := db.Table("qt_configurations").
|
||||
Select("id, uuid, owner_username, name, project_uuid").
|
||||
Find(&configs).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("load configurations: %w", err)
|
||||
}
|
||||
|
||||
codeRegex := regexp.MustCompile(`^(OPS-[0-9]{4})`)
|
||||
owners := make(map[string]struct{})
|
||||
projectNames := make(map[string]struct{})
|
||||
type candidate struct {
|
||||
config configRow
|
||||
code string
|
||||
owner string
|
||||
}
|
||||
candidates := make([]candidate, 0)
|
||||
|
||||
for _, cfg := range configs {
|
||||
match := codeRegex.FindStringSubmatch(strings.TrimSpace(cfg.Name))
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
owner := strings.TrimSpace(cfg.OwnerUsername)
|
||||
if owner == "" {
|
||||
owner = strings.TrimSpace(fallbackOwner)
|
||||
}
|
||||
if owner == "" {
|
||||
continue
|
||||
}
|
||||
code := match[1]
|
||||
owners[owner] = struct{}{}
|
||||
projectNames[code] = struct{}{}
|
||||
candidates = append(candidates, candidate{config: cfg, code: code, owner: owner})
|
||||
}
|
||||
|
||||
ownerList := setKeys(owners)
|
||||
nameList := setKeys(projectNames)
|
||||
existingProjects := make(map[string]*models.Project)
|
||||
if len(ownerList) > 0 && len(nameList) > 0 {
|
||||
var projects []models.Project
|
||||
if err := db.Where("owner_username IN ? AND name IN ?", ownerList, nameList).Find(&projects).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("load existing projects: %w", err)
|
||||
}
|
||||
for i := range projects {
|
||||
p := projects[i]
|
||||
existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p
|
||||
}
|
||||
}
|
||||
|
||||
actions := make([]migrationAction, 0)
|
||||
for _, c := range candidates {
|
||||
key := projectKey(c.owner, c.code)
|
||||
existing := existingProjects[key]
|
||||
|
||||
currentProject := ""
|
||||
if c.config.ProjectUUID != nil {
|
||||
currentProject = *c.config.ProjectUUID
|
||||
}
|
||||
|
||||
if existing != nil && currentProject == existing.UUID {
|
||||
continue
|
||||
}
|
||||
|
||||
action := migrationAction{
|
||||
ConfigID: c.config.ID,
|
||||
ConfigUUID: c.config.UUID,
|
||||
ConfigName: c.config.Name,
|
||||
OwnerUsername: c.owner,
|
||||
TargetProjectName: c.code,
|
||||
CurrentProject: currentProject,
|
||||
}
|
||||
if existing == nil {
|
||||
action.NeedCreateProject = true
|
||||
} else if !existing.IsActive {
|
||||
action.NeedReactivate = true
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
|
||||
return actions, existingProjects, nil
|
||||
}
|
||||
|
||||
func printPlan(actions []migrationAction) {
|
||||
createCount := 0
|
||||
reactivateCount := 0
|
||||
for _, a := range actions {
|
||||
if a.NeedCreateProject {
|
||||
createCount++
|
||||
}
|
||||
if a.NeedReactivate {
|
||||
reactivateCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Planned actions: %d\n", len(actions))
|
||||
fmt.Printf("Projects to create: %d\n", createCount)
|
||||
fmt.Printf("Projects to reactivate: %d\n", reactivateCount)
|
||||
fmt.Println("\nDetails:")
|
||||
|
||||
for _, a := range actions {
|
||||
extra := ""
|
||||
if a.NeedCreateProject {
|
||||
extra = " [create project]"
|
||||
} else if a.NeedReactivate {
|
||||
extra = " [reactivate project]"
|
||||
}
|
||||
current := a.CurrentProject
|
||||
if current == "" {
|
||||
current = "NULL"
|
||||
}
|
||||
fmt.Printf("- %s | owner=%s | \"%s\" | project: %s -> %s%s\n",
|
||||
a.ConfigUUID, a.OwnerUsername, a.ConfigName, current, a.TargetProjectName, extra)
|
||||
}
|
||||
}
|
||||
|
||||
func askForConfirmation() (bool, error) {
|
||||
fmt.Print("\nApply these changes? type 'yes' to continue: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(line), "yes"), nil
|
||||
}
|
||||
|
||||
func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[string]*models.Project) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
projectCache := make(map[string]*models.Project, len(existingProjects))
|
||||
for k, v := range existingProjects {
|
||||
cp := *v
|
||||
projectCache[k] = &cp
|
||||
}
|
||||
|
||||
for _, action := range actions {
|
||||
key := projectKey(action.OwnerUsername, action.TargetProjectName)
|
||||
project := projectCache[key]
|
||||
if project == nil {
|
||||
project = &models.Project{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: action.OwnerUsername,
|
||||
Name: action.TargetProjectName,
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
}
|
||||
if err := tx.Create(project).Error; err != nil {
|
||||
return fmt.Errorf("create project %s for owner %s: %w", action.TargetProjectName, action.OwnerUsername, err)
|
||||
}
|
||||
projectCache[key] = project
|
||||
} else if !project.IsActive {
|
||||
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
|
||||
return fmt.Errorf("reactivate project %s (%s): %w", project.Name, project.UUID, err)
|
||||
}
|
||||
project.IsActive = true
|
||||
}
|
||||
|
||||
if err := tx.Table("qt_configurations").Where("id = ?", action.ConfigID).Update("project_uuid", project.UUID).Error; err != nil {
|
||||
return fmt.Errorf("move configuration %s to project %s: %w", action.ConfigUUID, project.UUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func setKeys(set map[string]struct{}) []string {
|
||||
keys := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func projectKey(owner, name string) string {
|
||||
return owner + "||" + name
|
||||
}
|
||||
387
cmd/qfs/main.go
387
cmd/qfs/main.go
@@ -14,10 +14,12 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
@@ -55,6 +57,7 @@ func main() {
|
||||
|
||||
exePath, _ := os.Executable()
|
||||
slog.Info("starting qfs", "version", Version, "executable", exePath)
|
||||
appmeta.SetVersion(Version)
|
||||
|
||||
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
|
||||
if err != nil {
|
||||
@@ -162,8 +165,39 @@ func main() {
|
||||
slog.Info("migrations completed")
|
||||
}
|
||||
|
||||
// Always apply SQL migrations on startup when database is available.
|
||||
// This keeps schema in sync for long-running installations without manual steps.
|
||||
// If current DB user does not have enough privileges, continue startup in normal mode.
|
||||
if mariaDB != nil {
|
||||
sqlMigrationsPath := filepath.Join("migrations")
|
||||
needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath)
|
||||
if err != nil {
|
||||
if models.IsMigrationPermissionError(err) {
|
||||
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
|
||||
} else {
|
||||
slog.Error("startup SQL migrations check failed", "path", sqlMigrationsPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else if needsMigrations {
|
||||
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
|
||||
if models.IsMigrationPermissionError(err) {
|
||||
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
|
||||
} else {
|
||||
slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
slog.Info("startup SQL migrations applied", "path", sqlMigrationsPath)
|
||||
}
|
||||
} else {
|
||||
slog.Debug("startup SQL migrations not needed", "path", sqlMigrationsPath)
|
||||
}
|
||||
}
|
||||
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser)
|
||||
restartSig := make(chan struct{}, 1)
|
||||
|
||||
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser, restartSig)
|
||||
if err != nil {
|
||||
slog.Error("failed to setup router", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -205,9 +239,15 @@ func main() {
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
slog.Info("shutting down server...")
|
||||
shouldRestart := false
|
||||
select {
|
||||
case <-quit:
|
||||
slog.Info("shutting down server...")
|
||||
case <-restartSig:
|
||||
shouldRestart = true
|
||||
slog.Info("restarting application after connection settings update...")
|
||||
}
|
||||
|
||||
// Stop background sync worker first
|
||||
syncWorker.Stop()
|
||||
@@ -222,6 +262,10 @@ func main() {
|
||||
}
|
||||
|
||||
slog.Info("server stopped")
|
||||
|
||||
if shouldRestart {
|
||||
restartProcess()
|
||||
}
|
||||
}
|
||||
|
||||
func setConfigDefaults(cfg *config.Config) {
|
||||
@@ -392,7 +436,7 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string) (*gin.Engine, *sync.Service, error) {
|
||||
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) {
|
||||
// mariaDB may be nil if we're in offline mode
|
||||
|
||||
// Repositories
|
||||
@@ -424,6 +468,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
var alertService *alerts.Service
|
||||
var pricelistService *pricelist.Service
|
||||
var syncService *sync.Service
|
||||
var projectService *services.ProjectService
|
||||
|
||||
// Sync service always uses ConnectionManager (works offline and online)
|
||||
syncService = sync.NewService(connMgr, local)
|
||||
@@ -434,7 +479,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
||||
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo)
|
||||
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
|
||||
} else {
|
||||
// In offline mode, we still need to create services that don't require DB
|
||||
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
|
||||
@@ -442,7 +487,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
quoteService = services.NewQuoteService(nil, nil, pricingService)
|
||||
exportService = services.NewExportService(cfg.Export, nil)
|
||||
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService = pricelist.NewService(nil, nil, nil)
|
||||
pricelistService = pricelist.NewService(nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// isOnline function for local-first architecture
|
||||
@@ -451,8 +496,81 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
}
|
||||
|
||||
// Local-first configuration service (replaces old ConfigurationService)
|
||||
projectService = services.NewProjectService(local)
|
||||
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
|
||||
|
||||
// Data hygiene: remove empty nameless projects and ensure every configuration is attached to a project.
|
||||
if removed, err := local.ConsolidateSystemProjects(); err == nil && removed > 0 {
|
||||
slog.Info("consolidated duplicate local system projects", "removed", removed)
|
||||
}
|
||||
if removed, err := local.PurgeEmptyNamelessProjects(); err == nil && removed > 0 {
|
||||
slog.Info("purged empty nameless local projects", "removed", removed)
|
||||
}
|
||||
if err := local.BackfillConfigurationProjects(dbUsername); err != nil {
|
||||
slog.Warn("failed to backfill local configuration projects", "error", err)
|
||||
}
|
||||
if mariaDB != nil {
|
||||
serverProjectRepo := repository.NewProjectRepository(mariaDB)
|
||||
if removed, err := serverProjectRepo.PurgeEmptyNamelessProjects(); err == nil && removed > 0 {
|
||||
slog.Info("purged empty nameless server projects", "removed", removed)
|
||||
}
|
||||
if err := serverProjectRepo.EnsureSystemProjectsAndBackfillConfigurations(); err != nil {
|
||||
slog.Warn("failed to backfill server configuration projects", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
syncProjectsFromServer := func() {
|
||||
if !connMgr.IsOnline() {
|
||||
return
|
||||
}
|
||||
serverDB, err := connMgr.GetDB()
|
||||
if err != nil || serverDB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
projectRepo := repository.NewProjectRepository(serverDB)
|
||||
serverProjects, _, err := projectRepo.List(0, 10000, true)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for i := range serverProjects {
|
||||
sp := serverProjects[i]
|
||||
localProject, getErr := local.GetProjectByUUID(sp.UUID)
|
||||
if getErr == nil && localProject != nil {
|
||||
// Keep unsynced local changes intact.
|
||||
if localProject.SyncStatus == "pending" {
|
||||
continue
|
||||
}
|
||||
localProject.OwnerUsername = sp.OwnerUsername
|
||||
localProject.Name = sp.Name
|
||||
localProject.IsActive = sp.IsActive
|
||||
localProject.IsSystem = sp.IsSystem
|
||||
localProject.CreatedAt = sp.CreatedAt
|
||||
localProject.UpdatedAt = sp.UpdatedAt
|
||||
serverID := sp.ID
|
||||
localProject.ServerID = &serverID
|
||||
localProject.SyncStatus = "synced"
|
||||
localProject.SyncedAt = &now
|
||||
_ = local.SaveProject(localProject)
|
||||
continue
|
||||
}
|
||||
|
||||
lp := localdb.ProjectToLocal(&sp)
|
||||
lp.SyncStatus = "synced"
|
||||
lp.SyncedAt = &now
|
||||
_ = local.SaveProject(lp)
|
||||
}
|
||||
}
|
||||
|
||||
syncConfigurationsFromServer := func() {
|
||||
if !connMgr.IsOnline() {
|
||||
return
|
||||
}
|
||||
_, _ = configService.ImportFromServer()
|
||||
}
|
||||
|
||||
// Use filepath.Join for cross-platform path compatibility
|
||||
templatesPath := filepath.Join("web", "templates")
|
||||
|
||||
@@ -467,8 +585,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||
}
|
||||
|
||||
// Setup handler (for reconfiguration) - no restart signal in normal mode
|
||||
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, nil)
|
||||
// Setup handler (for reconfiguration)
|
||||
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
|
||||
}
|
||||
@@ -567,6 +685,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
router.GET("/", webHandler.Index)
|
||||
router.GET("/configs", webHandler.Configs)
|
||||
router.GET("/configurator", webHandler.Configurator)
|
||||
router.GET("/projects", webHandler.Projects)
|
||||
router.GET("/projects/:uuid", webHandler.ProjectDetail)
|
||||
router.GET("/pricelists", func(c *gin.Context) {
|
||||
// Redirect to admin/pricing with pricelists tab
|
||||
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
|
||||
@@ -620,6 +740,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
pricelists.GET("/:id", pricelistHandler.Get)
|
||||
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
||||
pricelists.POST("", pricelistHandler.Create)
|
||||
pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
|
||||
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
|
||||
pricelists.DELETE("/:id", pricelistHandler.Delete)
|
||||
}
|
||||
|
||||
@@ -627,15 +749,18 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
configs := api.Group("/configs")
|
||||
{
|
||||
configs.GET("", func(c *gin.Context) {
|
||||
syncConfigurationsFromServer()
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
status := c.DefaultQuery("status", "active")
|
||||
search := c.Query("search")
|
||||
if status != "active" && status != "archived" && status != "all" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
||||
return
|
||||
}
|
||||
|
||||
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status)
|
||||
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -647,6 +772,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"status": status,
|
||||
"search": search,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -776,6 +902,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusOK, config)
|
||||
})
|
||||
|
||||
configs.PATCH("/:uuid/project", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
var req struct {
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrConfigNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
})
|
||||
|
||||
configs.GET("/:uuid/versions", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
@@ -881,6 +1033,223 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
})
|
||||
}
|
||||
|
||||
projects := api.Group("/projects")
|
||||
{
|
||||
projects.GET("", func(c *gin.Context) {
|
||||
syncProjectsFromServer()
|
||||
syncConfigurationsFromServer()
|
||||
|
||||
status := c.DefaultQuery("status", "active")
|
||||
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
|
||||
if status != "active" && status != "archived" && status != "all" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
||||
return
|
||||
}
|
||||
|
||||
allProjects, err := projectService.ListByUser(dbUsername, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]models.Project, 0, len(allProjects))
|
||||
for i := range allProjects {
|
||||
p := allProjects[i]
|
||||
if status == "active" && !p.IsActive {
|
||||
continue
|
||||
}
|
||||
if status == "archived" && p.IsActive {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
|
||||
projectRows := make([]gin.H, 0, len(filtered))
|
||||
for i := range filtered {
|
||||
p := filtered[i]
|
||||
configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active")
|
||||
if err != nil {
|
||||
configs = &services.ProjectConfigurationsResult{
|
||||
ProjectUUID: p.UUID,
|
||||
Configs: []models.Configuration{},
|
||||
Total: 0,
|
||||
}
|
||||
}
|
||||
projectRows = append(projectRows, gin.H{
|
||||
"id": p.ID,
|
||||
"uuid": p.UUID,
|
||||
"owner_username": p.OwnerUsername,
|
||||
"name": p.Name,
|
||||
"is_active": p.IsActive,
|
||||
"is_system": p.IsSystem,
|
||||
"created_at": p.CreatedAt,
|
||||
"updated_at": p.UpdatedAt,
|
||||
"config_count": len(configs.Configs),
|
||||
"total": configs.Total,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"projects": projectRows,
|
||||
"status": status,
|
||||
"search": search,
|
||||
"total": len(projectRows),
|
||||
})
|
||||
})
|
||||
|
||||
projects.POST("", func(c *gin.Context) {
|
||||
var req services.CreateProjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
||||
return
|
||||
}
|
||||
project, err := projectService.Create(dbUsername, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, project)
|
||||
})
|
||||
|
||||
projects.GET("/:uuid", func(c *gin.Context) {
|
||||
project, err := projectService.GetByUUID(c.Param("uuid"), dbUsername)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, project)
|
||||
})
|
||||
|
||||
projects.PUT("/:uuid", func(c *gin.Context) {
|
||||
var req services.UpdateProjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
||||
return
|
||||
}
|
||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, project)
|
||||
})
|
||||
|
||||
projects.POST("/:uuid/archive", func(c *gin.Context) {
|
||||
if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "project archived"})
|
||||
})
|
||||
|
||||
projects.POST("/:uuid/reactivate", func(c *gin.Context) {
|
||||
if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
|
||||
})
|
||||
|
||||
projects.GET("/:uuid/configs", func(c *gin.Context) {
|
||||
syncConfigurationsFromServer()
|
||||
|
||||
status := c.DefaultQuery("status", "active")
|
||||
if status != "active" && status != "archived" && status != "all" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := projectService.ListConfigurations(c.Param("uuid"), dbUsername, status)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.Header("X-Config-Status", status)
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
projects.POST("/:uuid/configs", func(c *gin.Context) {
|
||||
var req services.CreateConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
projectUUID := c.Param("uuid")
|
||||
req.ProjectUUID = &projectUUID
|
||||
|
||||
config, err := configService.Create(dbUsername, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, config)
|
||||
})
|
||||
|
||||
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
projectUUID := c.Param("uuid")
|
||||
config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, config)
|
||||
})
|
||||
}
|
||||
|
||||
// Pricing admin (public - RBAC disabled)
|
||||
pricingAdmin := api.Group("/admin/pricing")
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestConfigurationVersioningAPI(t *testing.T) {
|
||||
|
||||
cfg := &config.Config{}
|
||||
setConfigDefaults(cfg)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester")
|
||||
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
@@ -136,6 +136,160 @@ func TestConfigurationVersioningAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
|
||||
moveToRepoRoot(t)
|
||||
|
||||
local, connMgr, configService := newAPITestStack(t)
|
||||
_ = configService
|
||||
|
||||
cfg := &config.Config{}
|
||||
setConfigDefaults(cfg)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1"}`)))
|
||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||
createProjectRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||
if createProjectRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String())
|
||||
}
|
||||
var project models.Project
|
||||
if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil {
|
||||
t.Fatalf("unmarshal project: %v", err)
|
||||
}
|
||||
|
||||
createCfgBody := []byte(`{"name":"Cfg A","items":[{"lot_name":"CPU","quantity":1,"unit_price":100}],"server_count":1}`)
|
||||
createCfgReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/configs", bytes.NewReader(createCfgBody))
|
||||
createCfgReq.Header.Set("Content-Type", "application/json")
|
||||
createCfgRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createCfgRec, createCfgReq)
|
||||
if createCfgRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create project config status=%d body=%s", createCfgRec.Code, createCfgRec.Body.String())
|
||||
}
|
||||
var createdCfg models.Configuration
|
||||
if err := json.Unmarshal(createCfgRec.Body.Bytes(), &createdCfg); err != nil {
|
||||
t.Fatalf("unmarshal project config: %v", err)
|
||||
}
|
||||
if createdCfg.ProjectUUID == nil || *createdCfg.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected config project_uuid=%s got=%v", project.UUID, createdCfg.ProjectUUID)
|
||||
}
|
||||
|
||||
cloneReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/configs/"+createdCfg.UUID+"/clone", bytes.NewReader([]byte(`{"name":"Cfg A Clone"}`)))
|
||||
cloneReq.Header.Set("Content-Type", "application/json")
|
||||
cloneRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(cloneRec, cloneReq)
|
||||
if cloneRec.Code != http.StatusCreated {
|
||||
t.Fatalf("clone in project status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
|
||||
}
|
||||
var cloneCfg models.Configuration
|
||||
if err := json.Unmarshal(cloneRec.Body.Bytes(), &cloneCfg); err != nil {
|
||||
t.Fatalf("unmarshal clone config: %v", err)
|
||||
}
|
||||
if cloneCfg.ProjectUUID == nil || *cloneCfg.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected clone project_uuid=%s got=%v", project.UUID, cloneCfg.ProjectUUID)
|
||||
}
|
||||
|
||||
projectConfigsReq := httptest.NewRequest(http.MethodGet, "/api/projects/"+project.UUID+"/configs", nil)
|
||||
projectConfigsRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(projectConfigsRec, projectConfigsReq)
|
||||
if projectConfigsRec.Code != http.StatusOK {
|
||||
t.Fatalf("project configs status=%d body=%s", projectConfigsRec.Code, projectConfigsRec.Body.String())
|
||||
}
|
||||
var projectConfigsResp struct {
|
||||
Configurations []models.Configuration `json:"configurations"`
|
||||
}
|
||||
if err := json.Unmarshal(projectConfigsRec.Body.Bytes(), &projectConfigsResp); err != nil {
|
||||
t.Fatalf("unmarshal project configs response: %v", err)
|
||||
}
|
||||
if len(projectConfigsResp.Configurations) != 2 {
|
||||
t.Fatalf("expected 2 project configs after clone, got %d", len(projectConfigsResp.Configurations))
|
||||
}
|
||||
|
||||
archiveReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/archive", nil)
|
||||
archiveRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(archiveRec, archiveReq)
|
||||
if archiveRec.Code != http.StatusOK {
|
||||
t.Fatalf("archive project status=%d body=%s", archiveRec.Code, archiveRec.Body.String())
|
||||
}
|
||||
|
||||
activeReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=active&page=1&per_page=20", nil)
|
||||
activeRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(activeRec, activeReq)
|
||||
if activeRec.Code != http.StatusOK {
|
||||
t.Fatalf("active configs status=%d body=%s", activeRec.Code, activeRec.Body.String())
|
||||
}
|
||||
var activeResp struct {
|
||||
Configurations []models.Configuration `json:"configurations"`
|
||||
}
|
||||
if err := json.Unmarshal(activeRec.Body.Bytes(), &activeResp); err != nil {
|
||||
t.Fatalf("unmarshal active configs response: %v", err)
|
||||
}
|
||||
if len(activeResp.Configurations) != 0 {
|
||||
t.Fatalf("expected no active configs after project archive, got %d", len(activeResp.Configurations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigMoveToProjectEndpoint(t *testing.T) {
|
||||
moveToRepoRoot(t)
|
||||
|
||||
local, connMgr, _ := newAPITestStack(t)
|
||||
cfg := &config.Config{}
|
||||
setConfigDefaults(cfg)
|
||||
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("setup router: %v", err)
|
||||
}
|
||||
|
||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project"}`)))
|
||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||
createProjectRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||
if createProjectRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String())
|
||||
}
|
||||
var project models.Project
|
||||
if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil {
|
||||
t.Fatalf("unmarshal project: %v", err)
|
||||
}
|
||||
|
||||
createConfigReq := httptest.NewRequest(http.MethodPost, "/api/configs", bytes.NewReader([]byte(`{"name":"Move Me","items":[],"notes":"","server_count":1}`)))
|
||||
createConfigReq.Header.Set("Content-Type", "application/json")
|
||||
createConfigRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createConfigRec, createConfigReq)
|
||||
if createConfigRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create config status=%d body=%s", createConfigRec.Code, createConfigRec.Body.String())
|
||||
}
|
||||
var created models.Configuration
|
||||
if err := json.Unmarshal(createConfigRec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("unmarshal config: %v", err)
|
||||
}
|
||||
|
||||
moveReq := httptest.NewRequest(http.MethodPatch, "/api/configs/"+created.UUID+"/project", bytes.NewReader([]byte(`{"project_uuid":"`+project.UUID+`"}`)))
|
||||
moveReq.Header.Set("Content-Type", "application/json")
|
||||
moveRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(moveRec, moveReq)
|
||||
if moveRec.Code != http.StatusOK {
|
||||
t.Fatalf("move config status=%d body=%s", moveRec.Code, moveRec.Body.String())
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID, nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("get config status=%d body=%s", getRec.Code, getRec.Body.String())
|
||||
}
|
||||
var updated models.Configuration
|
||||
if err := json.Unmarshal(getRec.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatalf("unmarshal updated config: %v", err)
|
||||
}
|
||||
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected moved project_uuid=%s, got %v", project.UUID, updated.ProjectUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
26
internal/appmeta/version.go
Normal file
26
internal/appmeta/version.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package appmeta
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
var appVersion atomic.Value
|
||||
|
||||
func init() {
|
||||
appVersion.Store("dev")
|
||||
}
|
||||
|
||||
// SetVersion configures the running application version string.
|
||||
func SetVersion(v string) {
|
||||
if v == "" {
|
||||
v = "dev"
|
||||
}
|
||||
appVersion.Store(v)
|
||||
}
|
||||
|
||||
// Version returns the running application version string.
|
||||
func Version() string {
|
||||
if v, ok := appVersion.Load().(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type PricelistHandler struct {
|
||||
@@ -22,8 +23,19 @@ func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *
|
||||
func (h *PricelistHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
activeOnly := c.DefaultQuery("active_only", "false") == "true"
|
||||
|
||||
pricelists, total, err := h.service.List(page, perPage)
|
||||
var (
|
||||
pricelists any
|
||||
total int64
|
||||
err error
|
||||
)
|
||||
|
||||
if activeOnly {
|
||||
pricelists, total, err = h.service.ListActive(page, perPage)
|
||||
} else {
|
||||
pricelists, total, err = h.service.List(page, perPage)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -87,6 +99,15 @@ func (h *PricelistHandler) Get(c *gin.Context) {
|
||||
|
||||
// Create creates a new pricelist from current prices
|
||||
func (h *PricelistHandler) Create(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
|
||||
}
|
||||
|
||||
// Get the database username as the creator
|
||||
createdBy := h.localDB.GetDBUser()
|
||||
if createdBy == "" {
|
||||
@@ -102,8 +123,85 @@ func (h *PricelistHandler) Create(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, pl)
|
||||
}
|
||||
|
||||
// CreateWithProgress creates a pricelist and streams progress updates over SSE.
|
||||
func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
|
||||
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||
if !canWrite {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "pricelist write is not allowed",
|
||||
"debug": debugInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
createdBy := h.localDB.GetDBUser()
|
||||
if createdBy == "" {
|
||||
createdBy = "unknown"
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
pl, err := h.service.CreateFromCurrentPrices(createdBy)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, pl)
|
||||
return
|
||||
}
|
||||
|
||||
sendProgress := func(payload gin.H) {
|
||||
c.SSEvent("progress", payload)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."})
|
||||
pl, err := h.service.CreateFromCurrentPricesWithProgress(createdBy, func(p pricelist.CreateProgress) {
|
||||
sendProgress(gin.H{
|
||||
"current": p.Current,
|
||||
"total": p.Total,
|
||||
"status": p.Status,
|
||||
"message": p.Message,
|
||||
"updated": p.Updated,
|
||||
"errors": p.Errors,
|
||||
"lot_name": p.LotName,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
sendProgress(gin.H{
|
||||
"current": 0,
|
||||
"total": 4,
|
||||
"status": "error",
|
||||
"message": fmt.Sprintf("Ошибка: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sendProgress(gin.H{
|
||||
"current": 4,
|
||||
"total": 4,
|
||||
"status": "completed",
|
||||
"message": "Готово",
|
||||
"pricelist": pl,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete deletes a pricelist by ID
|
||||
func (h *PricelistHandler) Delete(c *gin.Context) {
|
||||
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||
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 {
|
||||
@@ -119,6 +217,40 @@ func (h *PricelistHandler) Delete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
|
||||
}
|
||||
|
||||
// SetActive toggles active flag on a pricelist.
|
||||
func (h *PricelistHandler) SetActive(c *gin.Context) {
|
||||
canWrite, debugInfo := h.service.CanWriteDebug()
|
||||
if !canWrite {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "pricelist write is not allowed",
|
||||
"debug": debugInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.SetActive(uint(id), req.IsActive); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive})
|
||||
}
|
||||
|
||||
// GetItems returns items for a pricelist with pagination
|
||||
func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
|
||||
@@ -148,6 +148,8 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
|
||||
|
||||
// SaveConnection saves the connection settings and signals restart
|
||||
func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
existingSettings, _ := h.localDB.GetSettings()
|
||||
|
||||
host := c.PostForm("host")
|
||||
portStr := c.PostForm("port")
|
||||
database := c.PostForm("database")
|
||||
@@ -202,19 +204,29 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Always restart to properly initialize all services with the new connection
|
||||
restartRequired := h.restartSig == nil
|
||||
settingsChanged := existingSettings == nil ||
|
||||
existingSettings.Host != host ||
|
||||
existingSettings.Port != port ||
|
||||
existingSettings.Database != database ||
|
||||
existingSettings.User != user ||
|
||||
existingSettings.PasswordEncrypted != password
|
||||
|
||||
restartQueued := settingsChanged && h.restartSig != nil
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Settings saved.",
|
||||
"restart_required": restartRequired,
|
||||
"restart_required": settingsChanged,
|
||||
"restart_queued": restartQueued,
|
||||
})
|
||||
|
||||
// Signal restart after response is sent (if restart signal is configured)
|
||||
if h.restartSig != nil {
|
||||
if restartQueued {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond) // Give time for response to be sent
|
||||
h.restartSig <- struct{}{}
|
||||
select {
|
||||
case h.restartSig <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
}
|
||||
|
||||
// Load each page template with base
|
||||
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
|
||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
|
||||
for _, page := range simplePages {
|
||||
pagePath := filepath.Join(templatesPath, page)
|
||||
var tmpl *template.Template
|
||||
@@ -186,6 +186,17 @@ func (h *WebHandler) Configs(c *gin.Context) {
|
||||
h.render(c, "configs.html", gin.H{"ActivePage": "configs"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) Projects(c *gin.Context) {
|
||||
h.render(c, "projects.html", gin.H{"ActivePage": "projects"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) ProjectDetail(c *gin.Context) {
|
||||
h.render(c, "project_detail.html", gin.H{
|
||||
"ActivePage": "projects",
|
||||
"ProjectUUID": c.Param("uuid"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebHandler) AdminPricing(c *gin.Context) {
|
||||
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
|
||||
local := &LocalConfiguration{
|
||||
UUID: cfg.UUID,
|
||||
ProjectUUID: cfg.ProjectUUID,
|
||||
IsActive: true,
|
||||
Name: cfg.Name,
|
||||
Items: items,
|
||||
@@ -27,11 +28,12 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
PricelistID: cfg.PricelistID,
|
||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUserID: cfg.UserID,
|
||||
OriginalUserID: derefUint(cfg.UserID),
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
}
|
||||
|
||||
@@ -60,8 +62,8 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
|
||||
cfg := &models.Configuration{
|
||||
UUID: local.UUID,
|
||||
UserID: local.OriginalUserID,
|
||||
OwnerUsername: local.OriginalUsername,
|
||||
ProjectUUID: local.ProjectUUID,
|
||||
Name: local.Name,
|
||||
Items: items,
|
||||
TotalPrice: local.TotalPrice,
|
||||
@@ -69,6 +71,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
Notes: local.Notes,
|
||||
IsTemplate: local.IsTemplate,
|
||||
ServerCount: local.ServerCount,
|
||||
PricelistID: local.PricelistID,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
CreatedAt: local.CreatedAt,
|
||||
}
|
||||
@@ -76,10 +79,55 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
if local.ServerID != nil {
|
||||
cfg.ID = *local.ServerID
|
||||
}
|
||||
if local.OriginalUserID != 0 {
|
||||
userID := local.OriginalUserID
|
||||
cfg.UserID = &userID
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func derefUint(v *uint) uint {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func ProjectToLocal(project *models.Project) *LocalProject {
|
||||
local := &LocalProject{
|
||||
UUID: project.UUID,
|
||||
OwnerUsername: project.OwnerUsername,
|
||||
Name: project.Name,
|
||||
IsActive: project.IsActive,
|
||||
IsSystem: project.IsSystem,
|
||||
CreatedAt: project.CreatedAt,
|
||||
UpdatedAt: project.UpdatedAt,
|
||||
SyncStatus: "pending",
|
||||
}
|
||||
if project.ID > 0 {
|
||||
serverID := project.ID
|
||||
local.ServerID = &serverID
|
||||
}
|
||||
return local
|
||||
}
|
||||
|
||||
func LocalToProject(local *LocalProject) *models.Project {
|
||||
project := &models.Project{
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OwnerUsername,
|
||||
Name: local.Name,
|
||||
IsActive: local.IsActive,
|
||||
IsSystem: local.IsSystem,
|
||||
CreatedAt: local.CreatedAt,
|
||||
UpdatedAt: local.UpdatedAt,
|
||||
}
|
||||
if local.ServerID != nil {
|
||||
project.ID = *local.ServerID
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
// PricelistToLocal converts models.Pricelist to LocalPricelist
|
||||
func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||
name := pl.Notification
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"github.com/glebarez/sqlite"
|
||||
uuidpkg "github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
@@ -52,6 +55,7 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
// Auto-migrate all local tables
|
||||
if err := db.AutoMigrate(
|
||||
&ConnectionSettings{},
|
||||
&LocalProject{},
|
||||
&LocalConfiguration{},
|
||||
&LocalConfigurationVersion{},
|
||||
&LocalPricelist{},
|
||||
@@ -177,6 +181,216 @@ func (l *LocalDB) GetDBUser() string {
|
||||
|
||||
// Configuration methods
|
||||
|
||||
// Project methods
|
||||
|
||||
func (l *LocalDB) SaveProject(project *LocalProject) error {
|
||||
return l.db.Save(project).Error
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) {
|
||||
var projects []LocalProject
|
||||
query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername)
|
||||
if !includeArchived {
|
||||
query = query.Where("is_active = ?", true)
|
||||
}
|
||||
err := query.Order("created_at DESC").Find(&projects).Error
|
||||
return projects, err
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetAllProjects(includeArchived bool) ([]LocalProject, error) {
|
||||
var projects []LocalProject
|
||||
query := l.db.Model(&LocalProject{})
|
||||
if !includeArchived {
|
||||
query = query.Where("is_active = ?", true)
|
||||
}
|
||||
err := query.Order("created_at DESC").Find(&projects).Error
|
||||
return projects, err
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) {
|
||||
var project LocalProject
|
||||
if err := l.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
|
||||
var project LocalProject
|
||||
if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) {
|
||||
var configs []LocalConfiguration
|
||||
err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
|
||||
Order("created_at DESC").
|
||||
Find(&configs).Error
|
||||
return configs, err
|
||||
}
|
||||
|
||||
func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, error) {
|
||||
project := &LocalProject{}
|
||||
err := l.db.
|
||||
Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND is_system = ?", "Без проекта", true).
|
||||
Order("CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC").
|
||||
First(project).Error
|
||||
if err == nil {
|
||||
return project, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
project = &LocalProject{
|
||||
UUID: uuidpkg.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
SyncStatus: "pending",
|
||||
}
|
||||
if err := l.SaveProject(project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return project, nil
|
||||
}
|
||||
|
||||
// ConsolidateSystemProjects merges all "Без проекта" projects into one shared canonical project.
|
||||
// Configurations are reassigned to canonical UUID, duplicate projects are deleted.
|
||||
func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
|
||||
var removed int64
|
||||
err := l.db.Transaction(func(tx *gorm.DB) error {
|
||||
var canonical LocalProject
|
||||
err := tx.
|
||||
Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND is_system = ?", "Без проекта", true).
|
||||
Order("CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC").
|
||||
First(&canonical).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
now := time.Now()
|
||||
canonical = LocalProject{
|
||||
UUID: uuidpkg.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
SyncStatus: "pending",
|
||||
}
|
||||
if err := tx.Create(&canonical).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&LocalProject{}).
|
||||
Where("uuid = ?", canonical.UUID).
|
||||
Updates(map[string]any{
|
||||
"name": "Без проекта",
|
||||
"is_system": true,
|
||||
"is_active": true,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var duplicates []LocalProject
|
||||
if err := tx.Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND uuid <> ?", "Без проекта", canonical.UUID).
|
||||
Find(&duplicates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range duplicates {
|
||||
p := duplicates[i]
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("project_uuid = ?", p.UUID).
|
||||
Update("project_uuid", canonical.UUID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove stale pending project events for deleted UUIDs.
|
||||
if err := tx.Where("entity_type = ? AND entity_uuid = ?", "project", p.UUID).
|
||||
Delete(&PendingChange{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res := tx.Where("uuid = ?", p.UUID).Delete(&LocalProject{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
removed += res.RowsAffected
|
||||
}
|
||||
|
||||
// Backfill orphaned local configurations to canonical project.
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("project_uuid IS NULL OR TRIM(COALESCE(project_uuid, '')) = ''").
|
||||
Update("project_uuid", canonical.UUID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return removed, err
|
||||
}
|
||||
|
||||
// PurgeEmptyNamelessProjects removes service-trash projects that have no linked configurations:
|
||||
// 1) projects with empty names;
|
||||
// 2) duplicate "Без проекта" rows without configurations (case-insensitive, trimmed).
|
||||
func (l *LocalDB) PurgeEmptyNamelessProjects() (int64, error) {
|
||||
tx := l.db.Exec(`
|
||||
DELETE FROM local_projects
|
||||
WHERE (
|
||||
TRIM(COALESCE(name, '')) = ''
|
||||
OR LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
|
||||
)
|
||||
AND uuid NOT IN (
|
||||
SELECT DISTINCT project_uuid
|
||||
FROM local_configurations
|
||||
WHERE project_uuid IS NOT NULL AND project_uuid <> ''
|
||||
)`)
|
||||
return tx.RowsAffected, tx.Error
|
||||
}
|
||||
|
||||
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
|
||||
// If missing, it assigns system project "Без проекта" for configuration owner.
|
||||
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
||||
configs, err := l.GetConfigurations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range configs {
|
||||
cfg := configs[i]
|
||||
if cfg.ProjectUUID != nil && *cfg.ProjectUUID != "" {
|
||||
continue
|
||||
}
|
||||
owner := strings.TrimSpace(cfg.OriginalUsername)
|
||||
if owner == "" {
|
||||
owner = strings.TrimSpace(defaultOwner)
|
||||
}
|
||||
if owner == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
project, err := l.EnsureDefaultProject(owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.ProjectUUID = &project.UUID
|
||||
if saveErr := l.SaveConfiguration(&cfg); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveConfiguration saves a configuration to local SQLite
|
||||
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
|
||||
return l.db.Save(config).Error
|
||||
@@ -238,6 +452,7 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error {
|
||||
VersionNo: maxVersion + 1,
|
||||
Data: snapshot,
|
||||
ChangeNote: ¬e,
|
||||
AppVersion: appmeta.Version(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := tx.Create(version).Error; err != nil {
|
||||
@@ -315,6 +530,15 @@ func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, e
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetLocalPricelistByVersion returns a local pricelist by version string.
|
||||
func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetLocalPricelistByID returns a local pricelist by its local ID
|
||||
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
@@ -390,6 +614,25 @@ func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
||||
Update("is_used", isUsed).Error
|
||||
}
|
||||
|
||||
// RecalculateAllLocalPricelistUsage refreshes local_pricelists.is_used based on active configurations.
|
||||
func (l *LocalDB) RecalculateAllLocalPricelistUsage() error {
|
||||
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Exec(`
|
||||
UPDATE local_pricelists
|
||||
SET is_used = 1
|
||||
WHERE server_id IN (
|
||||
SELECT DISTINCT pricelist_id
|
||||
FROM local_configurations
|
||||
WHERE pricelist_id IS NOT NULL AND is_active = 1
|
||||
)
|
||||
`).Error
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteLocalPricelist deletes a pricelist and its items
|
||||
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
|
||||
// Delete items first
|
||||
@@ -472,6 +715,16 @@ func (l *LocalDB) MarkChangesSynced(ids []int64) error {
|
||||
return l.db.Where("id IN ?", ids).Delete(&PendingChange{}).Error
|
||||
}
|
||||
|
||||
// PurgeOrphanConfigurationPendingChanges removes configuration pending changes
|
||||
// whose entity_uuid no longer exists in local_configurations.
|
||||
func (l *LocalDB) PurgeOrphanConfigurationPendingChanges() (int64, error) {
|
||||
tx := l.db.Where(
|
||||
"entity_type = ? AND entity_uuid NOT IN (SELECT uuid FROM local_configurations)",
|
||||
"configuration",
|
||||
).Delete(&PendingChange{})
|
||||
return tx.RowsAffected, tx.Error
|
||||
}
|
||||
|
||||
// GetPendingCount returns the total number of pending changes (alias for CountPendingChanges)
|
||||
func (l *LocalDB) GetPendingCount() int64 {
|
||||
return l.CountPendingChanges()
|
||||
|
||||
60
internal/localdb/migration_projects_test.go
Normal file
60
internal/localdb/migration_projects_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "projects_backfill.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
cfg := &LocalConfiguration{
|
||||
UUID: "cfg-without-project",
|
||||
Name: "Cfg no project",
|
||||
Items: LocalConfigItems{},
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: "tester",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := local.SaveConfiguration(cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
if err := local.DB().
|
||||
Model(&LocalConfiguration{}).
|
||||
Where("uuid = ?", cfg.UUID).
|
||||
Update("project_uuid", nil).Error; err != nil {
|
||||
t.Fatalf("clear project_uuid: %v", err)
|
||||
}
|
||||
if err := local.DB().Where("id = ?", "2026_02_06_projects_backfill").Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||
t.Fatalf("delete local migration record: %v", err)
|
||||
}
|
||||
|
||||
if err := runLocalMigrations(local.DB()); err != nil {
|
||||
t.Fatalf("run local migrations: %v", err)
|
||||
}
|
||||
|
||||
updated, err := local.GetConfigurationByUUID(cfg.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("get updated config: %v", err)
|
||||
}
|
||||
if updated.ProjectUUID == nil || *updated.ProjectUUID == "" {
|
||||
t.Fatalf("expected project_uuid to be backfilled")
|
||||
}
|
||||
|
||||
project, err := local.GetProjectByUUID(*updated.ProjectUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("get system project: %v", err)
|
||||
}
|
||||
if project.Name != "Без проекта" {
|
||||
t.Fatalf("expected system project name, got %q", project.Name)
|
||||
}
|
||||
if !project.IsSystem {
|
||||
t.Fatalf("expected system project flag")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -36,6 +37,16 @@ var localMigrations = []localMigration{
|
||||
name: "Ensure is_active defaults to true for existing configurations",
|
||||
run: backfillConfigurationIsActive,
|
||||
},
|
||||
{
|
||||
id: "2026_02_06_projects_backfill",
|
||||
name: "Create default projects and attach existing configurations",
|
||||
run: backfillProjectsForConfigurations,
|
||||
},
|
||||
{
|
||||
id: "2026_02_06_pricelist_backfill",
|
||||
name: "Attach existing configurations to latest local pricelist and recalc usage",
|
||||
run: backfillConfigurationPricelists,
|
||||
},
|
||||
}
|
||||
|
||||
func runLocalMigrations(db *gorm.DB) error {
|
||||
@@ -103,6 +114,7 @@ func backfillConfigurationVersions(tx *gorm.DB) error {
|
||||
VersionNo: 1,
|
||||
Data: snapshot,
|
||||
ChangeNote: ¬e,
|
||||
AppVersion: "backfill",
|
||||
CreatedAt: chooseNonZeroTime(cfg.CreatedAt, time.Now()),
|
||||
}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
@@ -132,6 +144,93 @@ func backfillConfigurationIsActive(tx *gorm.DB) error {
|
||||
return tx.Exec("UPDATE local_configurations SET is_active = 1 WHERE is_active IS NULL").Error
|
||||
}
|
||||
|
||||
func backfillProjectsForConfigurations(tx *gorm.DB) error {
|
||||
var owners []string
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Distinct("original_username").
|
||||
Pluck("original_username", &owners).Error; err != nil {
|
||||
return fmt.Errorf("load owners for projects backfill: %w", err)
|
||||
}
|
||||
|
||||
for _, owner := range owners {
|
||||
project, err := ensureDefaultProjectTx(tx, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("original_username = ? AND (project_uuid IS NULL OR project_uuid = '')", owner).
|
||||
Update("project_uuid", project.UUID).Error; err != nil {
|
||||
return fmt.Errorf("assign default project for owner %s: %w", owner, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, error) {
|
||||
var project LocalProject
|
||||
err := tx.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта").
|
||||
First(&project).Error
|
||||
if err == nil {
|
||||
return &project, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("load system project for %s: %w", ownerUsername, err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
project = LocalProject{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: "Без проекта",
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
SyncStatus: "pending",
|
||||
}
|
||||
if err := tx.Create(&project).Error; err != nil {
|
||||
return nil, fmt.Errorf("create system project for %s: %w", ownerUsername, err)
|
||||
}
|
||||
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
||||
var latest LocalPricelist
|
||||
if err := tx.Order("created_at DESC").First(&latest).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("load latest local pricelist: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&LocalConfiguration{}).
|
||||
Where("pricelist_id IS NULL").
|
||||
Update("pricelist_id", latest.ServerID).Error; err != nil {
|
||||
return fmt.Errorf("backfill configuration pricelist_id: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
|
||||
return fmt.Errorf("reset local pricelist usage flags: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE local_pricelists
|
||||
SET is_used = 1
|
||||
WHERE server_id IN (
|
||||
SELECT DISTINCT pricelist_id
|
||||
FROM local_configurations
|
||||
WHERE pricelist_id IS NOT NULL AND is_active = 1
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("recalculate local pricelist usage flags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
||||
if candidate.IsZero() {
|
||||
return fallback
|
||||
|
||||
@@ -62,6 +62,7 @@ type LocalConfiguration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
|
||||
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
|
||||
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
@@ -71,6 +72,7 @@ type LocalConfiguration struct {
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -86,6 +88,24 @@ func (LocalConfiguration) TableName() string {
|
||||
return "local_configurations"
|
||||
}
|
||||
|
||||
type LocalProject struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id,omitempty"`
|
||||
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at,omitempty"`
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // local/synced/pending
|
||||
}
|
||||
|
||||
func (LocalProject) TableName() string {
|
||||
return "local_projects"
|
||||
}
|
||||
|
||||
// LocalConfigurationVersion stores immutable full snapshots for each configuration version
|
||||
type LocalConfigurationVersion struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
@@ -94,6 +114,7 @@ type LocalConfigurationVersion struct {
|
||||
Data string `gorm:"type:text;not null" json:"data"`
|
||||
ChangeNote *string `json:"change_note,omitempty"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||
CreatedAt time.Time `gorm:"not null;autoCreateTime;index:idx_lcv_config_created,sort:desc,priority:2" json:"created_at"`
|
||||
Configuration *LocalConfiguration `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"configuration,omitempty"`
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
"id": localCfg.ID,
|
||||
"uuid": localCfg.UUID,
|
||||
"server_id": localCfg.ServerID,
|
||||
"project_uuid": localCfg.ProjectUUID,
|
||||
"current_version_id": localCfg.CurrentVersionID,
|
||||
"is_active": localCfg.IsActive,
|
||||
"name": localCfg.Name,
|
||||
@@ -21,6 +22,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
"notes": localCfg.Notes,
|
||||
"is_template": localCfg.IsTemplate,
|
||||
"server_count": localCfg.ServerCount,
|
||||
"pricelist_id": localCfg.PricelistID,
|
||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||
"created_at": localCfg.CreatedAt,
|
||||
"updated_at": localCfg.UpdatedAt,
|
||||
@@ -40,6 +42,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
|
||||
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
var snapshot struct {
|
||||
ProjectUUID *string `json:"project_uuid"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Name string `json:"name"`
|
||||
Items LocalConfigItems `json:"items"`
|
||||
@@ -48,6 +51,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
PricelistID *uint `json:"pricelist_id"`
|
||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||
OriginalUserID uint `json:"original_user_id"`
|
||||
OriginalUsername string `json:"original_username"`
|
||||
@@ -64,6 +68,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
|
||||
return &LocalConfiguration{
|
||||
IsActive: isActive,
|
||||
ProjectUUID: snapshot.ProjectUUID,
|
||||
Name: snapshot.Name,
|
||||
Items: snapshot.Items,
|
||||
TotalPrice: snapshot.TotalPrice,
|
||||
@@ -71,6 +76,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||
Notes: snapshot.Notes,
|
||||
IsTemplate: snapshot.IsTemplate,
|
||||
ServerCount: snapshot.ServerCount,
|
||||
PricelistID: snapshot.PricelistID,
|
||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||
OriginalUserID: snapshot.OriginalUserID,
|
||||
OriginalUsername: snapshot.OriginalUsername,
|
||||
|
||||
@@ -42,8 +42,10 @@ func (c ConfigItems) Total() float64 {
|
||||
type Configuration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
UserID uint `gorm:"not null" json:"user_id"` // Legacy owner field (kept for backward compatibility)
|
||||
UserID *uint `json:"user_id,omitempty"` // Legacy field, no longer required for ownership
|
||||
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
|
||||
ProjectUUID *string `gorm:"size:36;index" json:"project_uuid,omitempty"`
|
||||
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||
@@ -51,6 +53,7 @@ type Configuration struct {
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ func AllModels() []interface{} {
|
||||
&User{},
|
||||
&Category{},
|
||||
&LotMetadata{},
|
||||
&Project{},
|
||||
&Configuration{},
|
||||
&PriceOverride{},
|
||||
&PricingAlert{},
|
||||
|
||||
18
internal/models/project.go
Normal file
18
internal/models/project.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Project struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Project) TableName() string {
|
||||
return "qt_projects"
|
||||
}
|
||||
227
internal/models/sql_migrations.go
Normal file
227
internal/models/sql_migrations.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SQLSchemaMigration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Filename string `gorm:"size:255;uniqueIndex;not null"`
|
||||
AppliedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func (SQLSchemaMigration) TableName() string {
|
||||
return "qt_schema_migrations"
|
||||
}
|
||||
|
||||
// NeedsSQLMigrations reports whether at least one SQL migration from migrationsDir
|
||||
// is not yet recorded in qt_schema_migrations.
|
||||
func NeedsSQLMigrations(db *gorm.DB, migrationsDir string) (bool, error) {
|
||||
files, err := listSQLMigrationFiles(migrationsDir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// If tracking table does not exist yet, migrations are required.
|
||||
if !db.Migrator().HasTable(&SQLSchemaMigration{}) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&SQLSchemaMigration{}).Where("filename IN ?", files).Count(&count).Error; err != nil {
|
||||
return false, fmt.Errorf("check applied migrations: %w", err)
|
||||
}
|
||||
|
||||
return count < int64(len(files)), nil
|
||||
}
|
||||
|
||||
// RunSQLMigrations applies SQL files from migrationsDir once and records them in qt_schema_migrations.
|
||||
// Local SQLite-only scripts are skipped automatically.
|
||||
func RunSQLMigrations(db *gorm.DB, migrationsDir string) error {
|
||||
if err := ensureSQLMigrationsTable(db); err != nil {
|
||||
return fmt.Errorf("migrate qt_schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
files, err := listSQLMigrationFiles(migrationsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, filename := range files {
|
||||
var count int64
|
||||
if err := db.Model(&SQLSchemaMigration{}).Where("filename = ?", filename).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", filename, err)
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(migrationsDir, filename)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", filename, err)
|
||||
}
|
||||
|
||||
statements := splitSQLStatements(string(content))
|
||||
if len(statements) == 0 {
|
||||
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
|
||||
return fmt.Errorf("record empty migration %s: %w", filename, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := executeMigrationStatements(db, filename, statements); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsMigrationPermissionError returns true if err indicates insufficient privileges
|
||||
// to create/alter/read migration metadata or target schema objects.
|
||||
func IsMigrationPermissionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var mysqlErr *mysqlDriver.MySQLError
|
||||
if errors.As(err, &mysqlErr) {
|
||||
switch mysqlErr.Number {
|
||||
case 1044, 1045, 1142, 1143, 1227:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
lower := strings.ToLower(err.Error())
|
||||
patterns := []string{
|
||||
"command denied to user",
|
||||
"access denied for user",
|
||||
"permission denied",
|
||||
"insufficient privilege",
|
||||
"sqlstate 42000",
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ensureSQLMigrationsTable(db *gorm.DB) error {
|
||||
stmt := `
|
||||
CREATE TABLE IF NOT EXISTS qt_schema_migrations (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
return db.Exec(stmt).Error
|
||||
}
|
||||
|
||||
func executeMigrationStatements(db *gorm.DB, filename string, statements []string) error {
|
||||
for _, stmt := range statements {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
if isIgnorableMigrationError(err.Error()) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("exec migration %s statement %q: %w", filename, stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSQLiteOnlyMigration(filename string) bool {
|
||||
lower := strings.ToLower(filename)
|
||||
return strings.Contains(lower, "local_")
|
||||
}
|
||||
|
||||
func isIgnorableMigrationError(message string) bool {
|
||||
lower := strings.ToLower(message)
|
||||
ignorable := []string{
|
||||
"duplicate column name",
|
||||
"duplicate key name",
|
||||
"already exists",
|
||||
"can't create table",
|
||||
"duplicate foreign key constraint name",
|
||||
"errno 121",
|
||||
}
|
||||
for _, pattern := range ignorable {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitSQLStatements(script string) []string {
|
||||
scanner := bufio.NewScanner(strings.NewReader(script))
|
||||
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||
|
||||
lines := make([]string, 0, 128)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "--") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
|
||||
combined := strings.Join(lines, "\n")
|
||||
raw := strings.Split(combined, ";")
|
||||
stmts := make([]string, 0, len(raw))
|
||||
for _, stmt := range raw {
|
||||
trimmed := strings.TrimSpace(stmt)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
stmts = append(stmts, trimmed)
|
||||
}
|
||||
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,6 +110,10 @@ func (r *ComponentRepository) Update(component *models.LotMetadata) error {
|
||||
return r.db.Save(component).Error
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
||||
return r.db.Create(component).Error
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
||||
|
||||
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
||||
var config models.Configuration
|
||||
err := r.db.Preload("User").First(&config, id).Error
|
||||
err := r.db.First(&config, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error
|
||||
|
||||
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
|
||||
var config models.Configuration
|
||||
err := r.db.Preload("User").Where("uuid = ?", uuid).First(&config).Error
|
||||
err := r.db.Where("uuid = ?", uuid).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -47,12 +47,11 @@ func (r *ConfigurationRepository) ListByUser(ownerUsername string, offset, limit
|
||||
var configs []models.Configuration
|
||||
var total int64
|
||||
|
||||
ownerScope := "owner_username = ? OR (COALESCE(owner_username, '') = '' AND user_id IN (SELECT id FROM qt_users WHERE username = ?))"
|
||||
ownerScope := "owner_username = ?"
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername, ownerUsername).Count(&total)
|
||||
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername).Count(&total)
|
||||
err := r.db.
|
||||
Preload("User").
|
||||
Where(ownerScope, ownerUsername, ownerUsername).
|
||||
Where(ownerScope, ownerUsername).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
@@ -67,7 +66,6 @@ func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Con
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where("is_template = ?", true).Count(&total)
|
||||
err := r.db.
|
||||
Preload("User").
|
||||
Where("is_template = ?", true).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
@@ -84,7 +82,6 @@ func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configura
|
||||
|
||||
r.db.Model(&models.Configuration{}).Count(&total)
|
||||
err := r.db.
|
||||
Preload("User").
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -62,6 +64,7 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
|
||||
for i, pl := range pricelists {
|
||||
var itemCount int64
|
||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
|
||||
usageCount, _ := r.CountUsage(pl.ID)
|
||||
|
||||
summaries[i] = models.PricelistSummary{
|
||||
ID: pl.ID,
|
||||
@@ -70,7 +73,7 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
|
||||
CreatedAt: pl.CreatedAt,
|
||||
CreatedBy: pl.CreatedBy,
|
||||
IsActive: pl.IsActive,
|
||||
UsageCount: pl.UsageCount,
|
||||
UsageCount: int(usageCount),
|
||||
ExpiresAt: pl.ExpiresAt,
|
||||
ItemCount: itemCount,
|
||||
}
|
||||
@@ -90,6 +93,9 @@ func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
|
||||
var itemCount int64
|
||||
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
|
||||
pricelist.ItemCount = int(itemCount)
|
||||
if usageCount, err := r.CountUsage(id); err == nil {
|
||||
pricelist.UsageCount = int(usageCount)
|
||||
}
|
||||
|
||||
return &pricelist, nil
|
||||
}
|
||||
@@ -130,13 +136,13 @@ func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
|
||||
|
||||
// Delete deletes a pricelist if usage_count is 0
|
||||
func (r *PricelistRepository) Delete(id uint) error {
|
||||
pricelist, err := r.GetByID(id)
|
||||
usageCount, err := r.CountUsage(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pricelist.UsageCount > 0 {
|
||||
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount)
|
||||
if usageCount > 0 {
|
||||
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount)
|
||||
}
|
||||
|
||||
// Delete items first
|
||||
@@ -206,18 +212,49 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetPriceForLot returns item price for a lot within a pricelist.
|
||||
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||
var item models.PricelistItem
|
||||
if err := r.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).First(&item).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.Price, nil
|
||||
}
|
||||
|
||||
// SetActive toggles active flag on a pricelist.
|
||||
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
|
||||
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error
|
||||
}
|
||||
|
||||
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
|
||||
func (r *PricelistRepository) GenerateVersion() (string, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
var count int64
|
||||
if err := r.db.Model(&models.Pricelist{}).
|
||||
Where("version LIKE ?", today+"%").
|
||||
Count(&count).Error; err != nil {
|
||||
return "", fmt.Errorf("counting today's pricelists: %w", err)
|
||||
var last models.Pricelist
|
||||
err := r.db.Model(&models.Pricelist{}).
|
||||
Select("version").
|
||||
Where("version LIKE ?", today+"-%").
|
||||
Order("version DESC").
|
||||
Limit(1).
|
||||
Take(&last).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Sprintf("%s-001", today), nil
|
||||
}
|
||||
return "", fmt.Errorf("loading latest today's pricelist version: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s-%03d", today, count+1), nil
|
||||
parts := strings.Split(last.Version, "-")
|
||||
if len(parts) < 4 {
|
||||
return "", fmt.Errorf("invalid pricelist version format: %s", last.Version)
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(parts[len(parts)-1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing pricelist sequence %q: %w", parts[len(parts)-1], err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s-%03d", today, n+1), nil
|
||||
}
|
||||
|
||||
// CanWrite checks if the current database user has INSERT permission on qt_pricelists
|
||||
@@ -276,6 +313,15 @@ func (r *PricelistRepository) DecrementUsageCount(id uint) error {
|
||||
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error
|
||||
}
|
||||
|
||||
// CountUsage returns number of configurations referencing pricelist.
|
||||
func (r *PricelistRepository) CountUsage(id uint) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.Table("qt_configurations").Where("pricelist_id = ?", id).Count(&count).Error; err != nil {
|
||||
return 0, fmt.Errorf("counting configurations for pricelist %d: %w", id, err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetExpiredUnused returns pricelists that are expired and unused
|
||||
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
|
||||
var pricelists []models.Pricelist
|
||||
|
||||
64
internal/repository/pricelist_test.go
Normal file
64
internal/repository/pricelist_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestGenerateVersion_FirstOfDay(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
|
||||
version, err := repo.GenerateVersion()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersion returned error: %v", err)
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
want := fmt.Sprintf("%s-001", today)
|
||||
if version != want {
|
||||
t.Fatalf("expected %s, got %s", want, version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
seed := []models.Pricelist{
|
||||
{Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
|
||||
{Version: fmt.Sprintf("%s-003", 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.GenerateVersion()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersion returned error: %v", err)
|
||||
}
|
||||
|
||||
want := fmt.Sprintf("%s-004", today)
|
||||
if version != want {
|
||||
t.Fatalf("expected %s, got %s", want, version)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
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{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return NewPricelistRepository(db)
|
||||
}
|
||||
169
internal/repository/project.go
Normal file
169
internal/repository/project.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProjectRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewProjectRepository(db *gorm.DB) *ProjectRepository {
|
||||
return &ProjectRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Create(project *models.Project) error {
|
||||
return r.db.Create(project).Error
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Update(project *models.Project) error {
|
||||
return r.db.Save(project).Error
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) {
|
||||
var project models.Project
|
||||
if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetSystemByOwner(ownerUsername string) (*models.Project, error) {
|
||||
var project models.Project
|
||||
if err := r.db.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта").
|
||||
First(&project).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) List(offset, limit int, includeArchived bool) ([]models.Project, int64, error) {
|
||||
var projects []models.Project
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.Project{})
|
||||
if !includeArchived {
|
||||
query = query.Where("is_active = ?", true)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&projects).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return projects, total, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) ListByOwner(ownerUsername string, includeArchived bool) ([]models.Project, error) {
|
||||
var projects []models.Project
|
||||
|
||||
query := r.db.Where("owner_username = ?", ownerUsername)
|
||||
if !includeArchived {
|
||||
query = query.Where("is_active = ?", true)
|
||||
}
|
||||
|
||||
if err := query.Order("created_at DESC").Find(&projects).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Archive(uuid string) error {
|
||||
return r.db.Model(&models.Project{}).Where("uuid = ?", uuid).Update("is_active", false).Error
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) Reactivate(uuid string) error {
|
||||
return r.db.Model(&models.Project{}).Where("uuid = ?", uuid).Update("is_active", true).Error
|
||||
}
|
||||
|
||||
// PurgeEmptyNamelessProjects removes service-trash projects that have no configurations attached:
|
||||
// 1) projects with empty names;
|
||||
// 2) duplicate "Без проекта" rows without configurations (case-insensitive, trimmed).
|
||||
func (r *ProjectRepository) PurgeEmptyNamelessProjects() (int64, error) {
|
||||
tx := r.db.Exec(`
|
||||
DELETE p
|
||||
FROM qt_projects p
|
||||
WHERE (
|
||||
TRIM(COALESCE(p.name, '')) = ''
|
||||
OR LOWER(TRIM(COALESCE(p.name, ''))) = LOWER('Без проекта')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM qt_configurations c
|
||||
WHERE c.project_uuid = p.uuid
|
||||
)`)
|
||||
return tx.RowsAffected, tx.Error
|
||||
}
|
||||
|
||||
// EnsureSystemProjectsAndBackfillConfigurations ensures there is a single shared system project
|
||||
// named "Без проекта", reassigns orphan/legacy links to it and removes duplicates.
|
||||
func (r *ProjectRepository) EnsureSystemProjectsAndBackfillConfigurations() error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
type row struct {
|
||||
UUID string `gorm:"column:uuid"`
|
||||
}
|
||||
var canonical row
|
||||
err := tx.Raw(`
|
||||
SELECT uuid
|
||||
FROM qt_projects
|
||||
WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
|
||||
AND is_system = TRUE
|
||||
ORDER BY CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC
|
||||
LIMIT 1`).Scan(&canonical).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if canonical.UUID == "" {
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO qt_projects (uuid, owner_username, name, is_active, is_system, created_at, updated_at)
|
||||
VALUES (UUID(), '', 'Без проекта', TRUE, TRUE, NOW(), NOW())`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT uuid
|
||||
FROM qt_projects
|
||||
WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1`).Scan(&canonical).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if canonical.UUID == "" {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE qt_projects
|
||||
SET name = 'Без проекта',
|
||||
is_active = TRUE,
|
||||
is_system = TRUE
|
||||
WHERE uuid = ?`, canonical.UUID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE qt_configurations
|
||||
SET project_uuid = ?
|
||||
WHERE project_uuid IS NULL OR project_uuid = ''`, canonical.UUID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE qt_configurations c
|
||||
JOIN qt_projects p ON p.uuid = c.project_uuid
|
||||
SET c.project_uuid = ?
|
||||
WHERE LOWER(TRIM(COALESCE(p.name, ''))) = LOWER('Без проекта')
|
||||
AND p.uuid <> ?`, canonical.UUID, canonical.UUID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Exec(`
|
||||
DELETE FROM qt_projects
|
||||
WHERE LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
|
||||
AND uuid <> ?`, canonical.UUID).Error
|
||||
})
|
||||
}
|
||||
@@ -22,18 +22,24 @@ type ConfigurationGetter interface {
|
||||
|
||||
type ConfigurationService struct {
|
||||
configRepo *repository.ConfigurationRepository
|
||||
projectRepo *repository.ProjectRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
quoteService *QuoteService
|
||||
}
|
||||
|
||||
func NewConfigurationService(
|
||||
configRepo *repository.ConfigurationRepository,
|
||||
projectRepo *repository.ProjectRepository,
|
||||
componentRepo *repository.ComponentRepository,
|
||||
pricelistRepo *repository.PricelistRepository,
|
||||
quoteService *QuoteService,
|
||||
) *ConfigurationService {
|
||||
return &ConfigurationService{
|
||||
configRepo: configRepo,
|
||||
projectRepo: projectRepo,
|
||||
componentRepo: componentRepo,
|
||||
pricelistRepo: pricelistRepo,
|
||||
quoteService: quoteService,
|
||||
}
|
||||
}
|
||||
@@ -41,13 +47,24 @@ func NewConfigurationService(
|
||||
type CreateConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
Items models.ConfigItems `json:"items"`
|
||||
ProjectUUID *string `json:"project_uuid,omitempty"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
@@ -58,6 +75,7 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
||||
config := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: projectUUID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -65,6 +83,7 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
PricelistID: pricelistID,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(config); err != nil {
|
||||
@@ -101,6 +120,15 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
@@ -109,12 +137,14 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
||||
}
|
||||
|
||||
config.Name = req.Name
|
||||
config.ProjectUUID = projectUUID
|
||||
config.Items = req.Items
|
||||
config.TotalPrice = &total
|
||||
config.CustomPrice = req.CustomPrice
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
config.PricelistID = pricelistID
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
@@ -156,10 +186,21 @@ func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUID(configUUID, ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Create copy with new UUID and name
|
||||
total := original.Items.Total()
|
||||
@@ -172,6 +213,7 @@ func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, ne
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -179,6 +221,7 @@ func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, ne
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false, // Clone is never a template
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
@@ -229,18 +272,29 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(config.OwnerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
config.Name = req.Name
|
||||
config.ProjectUUID = projectUUID
|
||||
config.Items = req.Items
|
||||
config.TotalPrice = &total
|
||||
config.CustomPrice = req.CustomPrice
|
||||
config.Notes = req.Notes
|
||||
config.IsTemplate = req.IsTemplate
|
||||
config.ServerCount = req.ServerCount
|
||||
config.PricelistID = pricelistID
|
||||
|
||||
if err := s.configRepo.Update(config); err != nil {
|
||||
return nil, err
|
||||
@@ -275,10 +329,21 @@ func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*model
|
||||
|
||||
// CloneNoAuth clones configuration without ownership check
|
||||
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
|
||||
return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.configRepo.GetByUUID(configUUID)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
total := original.Items.Total()
|
||||
if original.ServerCount > 1 {
|
||||
@@ -288,6 +353,7 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ow
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -295,6 +361,7 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ow
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
@@ -304,6 +371,43 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ow
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
|
||||
_ = ownerUsername
|
||||
if s.projectRepo == nil {
|
||||
return projectUUID, nil
|
||||
}
|
||||
if projectUUID == nil || *projectUUID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
project, err := s.projectRepo.GetByUUID(*projectUUID)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if !project.IsActive {
|
||||
return nil, errors.New("project is archived")
|
||||
}
|
||||
|
||||
return &project.UUID, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
|
||||
if s.pricelistRepo == nil {
|
||||
return pricelistID, nil
|
||||
}
|
||||
if pricelistID != nil && *pricelistID > 0 {
|
||||
if _, err := s.pricelistRepo.GetByID(*pricelistID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pricelistID, nil
|
||||
}
|
||||
latest, err := s.pricelistRepo.GetLatestActive()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &latest.ID, nil
|
||||
}
|
||||
|
||||
// RefreshPricesNoAuth refreshes prices without ownership check
|
||||
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
@@ -311,8 +415,30 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
var latestPricelistID *uint
|
||||
if s.pricelistRepo != nil {
|
||||
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
|
||||
latestPricelistID = &pl.ID
|
||||
}
|
||||
}
|
||||
|
||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||
for i, item := range config.Items {
|
||||
if latestPricelistID != nil {
|
||||
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: price,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if s.componentRepo == nil {
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||
if err != nil || metadata.CurrentPrice == nil {
|
||||
updatedItems[i] = item
|
||||
@@ -333,6 +459,9 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
if latestPricelistID != nil {
|
||||
config.PricelistID = latestPricelistID
|
||||
}
|
||||
now := time.Now()
|
||||
config.PriceUpdatedAt = &now
|
||||
|
||||
@@ -366,10 +495,32 @@ func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string)
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
var latestPricelistID *uint
|
||||
if s.pricelistRepo != nil {
|
||||
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
|
||||
latestPricelistID = &pl.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Update prices for all items
|
||||
updatedItems := make(models.ConfigItems, len(config.Items))
|
||||
for i, item := range config.Items {
|
||||
if latestPricelistID != nil {
|
||||
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
|
||||
updatedItems[i] = models.ConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: price,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Get current component price
|
||||
if s.componentRepo == nil {
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
metadata, err := s.componentRepo.GetByLotName(item.LotName)
|
||||
if err != nil || metadata.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
@@ -395,6 +546,9 @@ func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string)
|
||||
}
|
||||
|
||||
config.TotalPrice = &total
|
||||
if latestPricelistID != nil {
|
||||
config.PricelistID = latestPricelistID
|
||||
}
|
||||
|
||||
// Set price update timestamp
|
||||
now := time.Now()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
@@ -54,6 +55,15 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
}
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
@@ -62,6 +72,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
cfg := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: projectUUID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -69,6 +80,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
PricelistID: pricelistID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -117,6 +129,15 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
@@ -124,6 +145,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
|
||||
// Update fields
|
||||
localCfg.Name = req.Name
|
||||
localCfg.ProjectUUID = projectUUID
|
||||
localCfg.Items = localdb.LocalConfigItems{}
|
||||
for _, item := range req.Items {
|
||||
localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{
|
||||
@@ -137,6 +159,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
localCfg.Notes = req.Notes
|
||||
localCfg.IsTemplate = req.IsTemplate
|
||||
localCfg.ServerCount = req.ServerCount
|
||||
localCfg.PricelistID = pricelistID
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
@@ -209,10 +232,21 @@ func (s *LocalConfigurationService) Rename(uuid string, ownerUsername string, ne
|
||||
|
||||
// Clone clones a configuration
|
||||
func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUID(configUUID, ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
total := original.Items.Total()
|
||||
if original.ServerCount > 1 {
|
||||
@@ -222,6 +256,7 @@ func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername strin
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -229,6 +264,7 @@ func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername strin
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -299,10 +335,28 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
// Refresh local pricelists when online and use latest active/local pricelist for recalculation.
|
||||
if s.isOnline() {
|
||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||
}
|
||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||
|
||||
// Update prices for all items
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
// Get current component price from local cache
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
||||
if err == nil && price > 0 {
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: price,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to current component price from local cache
|
||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||
if err != nil || component.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
@@ -328,6 +382,9 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
}
|
||||
|
||||
localCfg.TotalPrice = &total
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
localCfg.PricelistID = &latestPricelist.ServerID
|
||||
}
|
||||
|
||||
// Set price update timestamp and mark for sync
|
||||
now := time.Now()
|
||||
@@ -361,12 +418,22 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := req.Items.Total()
|
||||
if req.ServerCount > 1 {
|
||||
total *= float64(req.ServerCount)
|
||||
}
|
||||
|
||||
localCfg.Name = req.Name
|
||||
localCfg.ProjectUUID = projectUUID
|
||||
localCfg.Items = localdb.LocalConfigItems{}
|
||||
for _, item := range req.Items {
|
||||
localCfg.Items = append(localCfg.Items, localdb.LocalConfigItem{
|
||||
@@ -380,6 +447,7 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
localCfg.Notes = req.Notes
|
||||
localCfg.IsTemplate = req.IsTemplate
|
||||
localCfg.ServerCount = req.ServerCount
|
||||
localCfg.PricelistID = pricelistID
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
@@ -439,10 +507,21 @@ func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*
|
||||
|
||||
// CloneNoAuth clones configuration without ownership check
|
||||
func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
|
||||
return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil)
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUIDNoAuth(configUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedProjectUUID := original.ProjectUUID
|
||||
if projectUUID != nil {
|
||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
total := original.Items.Total()
|
||||
if original.ServerCount > 1 {
|
||||
@@ -452,6 +531,7 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
ProjectUUID: resolvedProjectUUID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
@@ -459,6 +539,7 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
PricelistID: original.PricelistID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -470,24 +551,59 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
// SetProjectNoAuth moves configuration to a different project without ownership check.
|
||||
func (s *LocalConfigurationService) SetProjectNoAuth(uuid string, projectUUID string) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
var resolved *string
|
||||
trimmed := strings.TrimSpace(projectUUID)
|
||||
if trimmed == "" {
|
||||
resolved, err = s.resolveProjectUUID(localCfg.OriginalUsername, &projectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
project, getErr := s.localDB.GetProjectByUUID(trimmed)
|
||||
if getErr != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if !project.IsActive {
|
||||
return nil, errors.New("project is archived")
|
||||
}
|
||||
resolved = &project.UUID
|
||||
}
|
||||
|
||||
localCfg.ProjectUUID = resolved
|
||||
localCfg.UpdatedAt = time.Now()
|
||||
localCfg.SyncStatus = "pending"
|
||||
return s.saveWithVersionAndPending(localCfg, "update", "")
|
||||
}
|
||||
|
||||
// ListAll returns all configurations without user filter
|
||||
func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
|
||||
return s.ListAllWithStatus(page, perPage, "active")
|
||||
return s.ListAllWithStatus(page, perPage, "active", "")
|
||||
}
|
||||
|
||||
// ListAllWithStatus returns configurations filtered by status: active|archived|all.
|
||||
func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string) ([]models.Configuration, int64, error) {
|
||||
func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string, search string) ([]models.Configuration, int64, error) {
|
||||
localConfigs, err := s.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
configs := make([]models.Configuration, len(localConfigs))
|
||||
configs = configs[:0]
|
||||
for _, lc := range localConfigs {
|
||||
if !matchesConfigStatus(lc.IsActive, status) {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(strings.ToLower(lc.Name), search) {
|
||||
continue
|
||||
}
|
||||
configs = append(configs, *localdb.LocalToConfiguration(&lc))
|
||||
}
|
||||
|
||||
@@ -562,10 +678,27 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if s.isOnline() {
|
||||
_ = s.syncService.SyncPricelistsIfNeeded()
|
||||
}
|
||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||
|
||||
// Update prices for all items
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
// Get current component price from local cache
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
|
||||
if err == nil && price > 0 {
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: price,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to current component price from local cache
|
||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||
if err != nil || component.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
@@ -591,6 +724,9 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
}
|
||||
|
||||
localCfg.TotalPrice = &total
|
||||
if latestErr == nil && latestPricelist != nil {
|
||||
localCfg.PricelistID = &latestPricelist.ServerID
|
||||
}
|
||||
|
||||
// Set price update timestamp and mark for sync
|
||||
now := time.Now()
|
||||
@@ -737,6 +873,9 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||
return fmt.Errorf("enqueue create pending change: %w", err)
|
||||
}
|
||||
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
|
||||
return fmt.Errorf("recalculate local pricelist usage: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -776,6 +915,9 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
||||
return fmt.Errorf("enqueue %s pending change: %w", operation, err)
|
||||
}
|
||||
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
|
||||
return fmt.Errorf("recalculate local pricelist usage: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -820,6 +962,7 @@ func (s *LocalConfigurationService) appendVersionTx(
|
||||
Data: snapshot,
|
||||
ChangeNote: &changeNote,
|
||||
CreatedBy: createdByPtr,
|
||||
AppVersion: appmeta.Version(),
|
||||
}
|
||||
|
||||
if err := tx.Create(version).Error; err != nil {
|
||||
@@ -879,6 +1022,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
||||
current.Notes = rollbackData.Notes
|
||||
current.IsTemplate = rollbackData.IsTemplate
|
||||
current.ServerCount = rollbackData.ServerCount
|
||||
current.PricelistID = rollbackData.PricelistID
|
||||
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
|
||||
current.UpdatedAt = time.Now()
|
||||
current.SyncStatus = "pending"
|
||||
@@ -915,6 +1059,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
||||
Data: target.Data,
|
||||
ChangeNote: &changeNote,
|
||||
CreatedBy: stringPtrOrNil(userID),
|
||||
AppVersion: appmeta.Version(),
|
||||
}
|
||||
|
||||
if err := tx.Create(version).Error; err != nil {
|
||||
@@ -935,6 +1080,9 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
||||
if err := s.enqueueConfigurationPendingChangeTx(tx, ¤t, "rollback", version, userID); err != nil {
|
||||
return fmt.Errorf("enqueue rollback pending change: %w", err)
|
||||
}
|
||||
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
|
||||
return fmt.Errorf("recalculate local pricelist usage: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -957,6 +1105,8 @@ func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx(
|
||||
EventID: uuid.New().String(),
|
||||
IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation),
|
||||
ConfigurationUUID: localCfg.UUID,
|
||||
ProjectUUID: localCfg.ProjectUUID,
|
||||
PricelistID: localCfg.PricelistID,
|
||||
Operation: operation,
|
||||
CurrentVersionID: version.ID,
|
||||
CurrentVersionNo: version.VersionNo,
|
||||
@@ -990,6 +1140,21 @@ func (s *LocalConfigurationService) decodeConfigurationSnapshot(data string) (*l
|
||||
return localdb.DecodeConfigurationSnapshot(data)
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) recalculateLocalPricelistUsageTx(tx *gorm.DB) error {
|
||||
if err := tx.Model(&localdb.LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec(`
|
||||
UPDATE local_pricelists
|
||||
SET is_used = 1
|
||||
WHERE server_id IN (
|
||||
SELECT DISTINCT pricelist_id
|
||||
FROM local_configurations
|
||||
WHERE pricelist_id IS NOT NULL AND is_active = 1
|
||||
)
|
||||
`).Error
|
||||
}
|
||||
|
||||
func stringPtrOrNil(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
@@ -1010,3 +1175,50 @@ func matchesConfigStatus(isActive bool, status string) bool {
|
||||
return isActive
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
|
||||
if ownerUsername == "" {
|
||||
ownerUsername = s.localDB.GetDBUser()
|
||||
}
|
||||
|
||||
if projectUUID == nil || strings.TrimSpace(*projectUUID) == "" {
|
||||
project, err := s.localDB.EnsureDefaultProject(ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project.UUID, nil
|
||||
}
|
||||
|
||||
requested := strings.TrimSpace(*projectUUID)
|
||||
project, err := s.localDB.GetProjectByUUID(requested)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if !project.IsActive {
|
||||
return nil, errors.New("project is archived")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,51 +1,116 @@
|
||||
package pricelist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *repository.PricelistRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
pricingSvc *pricing.Service
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service {
|
||||
type CreateProgress struct {
|
||||
Current int
|
||||
Total int
|
||||
Status string
|
||||
Message string
|
||||
Updated int
|
||||
Errors int
|
||||
LotName string
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
componentRepo: componentRepo,
|
||||
pricingSvc: pricingSvc,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
||||
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
||||
return s.CreateFromCurrentPricesWithProgress(createdBy, nil)
|
||||
}
|
||||
|
||||
// CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress.
|
||||
func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgress func(CreateProgress)) (*models.Pricelist, error) {
|
||||
if s.repo == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
||||
}
|
||||
|
||||
version, err := s.repo.GenerateVersion()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating version: %w", err)
|
||||
report := func(p CreateProgress) {
|
||||
if onProgress != nil {
|
||||
onProgress(p)
|
||||
}
|
||||
}
|
||||
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
|
||||
|
||||
updated, errs := 0, 0
|
||||
if s.pricingSvc != nil {
|
||||
report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"})
|
||||
updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) {
|
||||
if p.Total <= 0 {
|
||||
return
|
||||
}
|
||||
phaseCurrent := 1 + int(float64(p.Current)/float64(p.Total)*90.0)
|
||||
if phaseCurrent > 91 {
|
||||
phaseCurrent = 91
|
||||
}
|
||||
report(CreateProgress{
|
||||
Current: phaseCurrent,
|
||||
Total: 100,
|
||||
Status: "recalculating",
|
||||
Message: "Обновление цен компонентов",
|
||||
Updated: p.Updated,
|
||||
Errors: p.Errors,
|
||||
LotName: p.LotName,
|
||||
})
|
||||
})
|
||||
}
|
||||
report(CreateProgress{Current: 92, Total: 100, Status: "recalculated", Message: "Цены обновлены", Updated: updated, Errors: errs})
|
||||
|
||||
report(CreateProgress{Current: 95, Total: 100, Status: "snapshot", Message: "Создание снимка прайслиста"})
|
||||
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
|
||||
const maxCreateAttempts = 5
|
||||
var pricelist *models.Pricelist
|
||||
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
|
||||
version, err := s.repo.GenerateVersion()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating version: %w", err)
|
||||
}
|
||||
|
||||
pricelist := &models.Pricelist{
|
||||
Version: version,
|
||||
CreatedBy: createdBy,
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiresAt,
|
||||
}
|
||||
pricelist = &models.Pricelist{
|
||||
Version: version,
|
||||
CreatedBy: createdBy,
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiresAt,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(pricelist); err != nil {
|
||||
return nil, fmt.Errorf("creating pricelist: %w", err)
|
||||
if err := s.repo.Create(pricelist); err != nil {
|
||||
if isVersionConflictError(err) && attempt < maxCreateAttempts {
|
||||
slog.Warn("pricelist version conflict, retrying",
|
||||
"attempt", attempt,
|
||||
"version", version,
|
||||
"error", err,
|
||||
)
|
||||
time.Sleep(time.Duration(attempt*25) * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("creating pricelist: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Get all components with prices from qt_lot_metadata
|
||||
@@ -86,10 +151,19 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist,
|
||||
"items", len(items),
|
||||
"created_by", createdBy,
|
||||
)
|
||||
report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs})
|
||||
|
||||
return pricelist, nil
|
||||
}
|
||||
|
||||
func isVersionConflictError(err error) bool {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "duplicate entry") && strings.Contains(msg, "idx_qt_pricelists_version")
|
||||
}
|
||||
|
||||
// List returns pricelists with pagination
|
||||
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
// If no database connection (offline mode), return empty list
|
||||
@@ -107,6 +181,21 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
|
||||
return s.repo.List(offset, perPage)
|
||||
}
|
||||
|
||||
// ListActive returns active pricelists with pagination.
|
||||
func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
if s.repo == nil {
|
||||
return []models.PricelistSummary{}, 0, nil
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.ListActive(offset, perPage)
|
||||
}
|
||||
|
||||
// GetByID returns a pricelist by ID
|
||||
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
|
||||
if s.repo == nil {
|
||||
@@ -138,6 +227,22 @@ func (s *Service) Delete(id uint) error {
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
|
||||
// SetActive toggles active state for a pricelist.
|
||||
func (s *Service) SetActive(id uint, isActive bool) error {
|
||||
if s.repo == nil {
|
||||
return fmt.Errorf("offline mode: cannot update pricelists")
|
||||
}
|
||||
return s.repo.SetActive(id, isActive)
|
||||
}
|
||||
|
||||
// GetPriceForLot returns price by pricelist/lot.
|
||||
func (s *Service) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||
if s.repo == nil {
|
||||
return 0, fmt.Errorf("offline mode: pricelist service not available")
|
||||
}
|
||||
return s.repo.GetPriceForLot(pricelistID, lotName)
|
||||
}
|
||||
|
||||
// CanWrite returns true if the user can create pricelists
|
||||
func (s *Service) CanWrite() bool {
|
||||
if s.repo == nil {
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
priceRepo *repository.PriceRepository
|
||||
config config.PricingConfig
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type RecalculateProgress struct {
|
||||
Current int
|
||||
Total int
|
||||
LotName string
|
||||
Updated int
|
||||
Errors int
|
||||
}
|
||||
|
||||
func NewService(
|
||||
@@ -19,10 +30,16 @@ func NewService(
|
||||
priceRepo *repository.PriceRepository,
|
||||
cfg config.PricingConfig,
|
||||
) *Service {
|
||||
var db *gorm.DB
|
||||
if componentRepo != nil {
|
||||
db = componentRepo.DB()
|
||||
}
|
||||
|
||||
return &Service{
|
||||
componentRepo: componentRepo,
|
||||
priceRepo: priceRepo,
|
||||
config: cfg,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,27 +196,183 @@ type PriceStats struct {
|
||||
|
||||
// RecalculateAllPrices recalculates prices for all components
|
||||
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
|
||||
// Get all components
|
||||
filter := repository.ComponentFilter{}
|
||||
offset := 0
|
||||
limit := 100
|
||||
return s.RecalculateAllPricesWithProgress(nil)
|
||||
}
|
||||
|
||||
for {
|
||||
components, _, err := s.componentRepo.List(filter, offset, limit)
|
||||
if err != nil || len(components) == 0 {
|
||||
break
|
||||
}
|
||||
// RecalculateAllPricesWithProgress recalculates prices and reports progress.
|
||||
func (s *Service) RecalculateAllPricesWithProgress(onProgress func(RecalculateProgress)) (updated int, errors int) {
|
||||
if s.db == nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
for _, comp := range components {
|
||||
if err := s.UpdateComponentPrice(comp.LotName); err != nil {
|
||||
errors++
|
||||
} else {
|
||||
updated++
|
||||
// Logic mirrors "Обновить цены" in admin pricing.
|
||||
var components []models.LotMetadata
|
||||
if err := s.db.Find(&components).Error; err != nil {
|
||||
return 0, len(components)
|
||||
}
|
||||
total := len(components)
|
||||
|
||||
var allLotNames []string
|
||||
_ = s.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames).Error
|
||||
|
||||
type lotDate struct {
|
||||
Lot string
|
||||
Date time.Time
|
||||
}
|
||||
var latestDates []lotDate
|
||||
_ = s.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates).Error
|
||||
lotLatestDate := make(map[string]time.Time, len(latestDates))
|
||||
for _, ld := range latestDates {
|
||||
lotLatestDate[ld.Lot] = ld.Date
|
||||
}
|
||||
|
||||
var skipped, manual, unchanged int
|
||||
now := time.Now()
|
||||
current := 0
|
||||
|
||||
for _, comp := range components {
|
||||
current++
|
||||
reportProgress := func() {
|
||||
if onProgress != nil && (current%10 == 0 || current == total) {
|
||||
onProgress(RecalculateProgress{
|
||||
Current: current,
|
||||
Total: total,
|
||||
LotName: comp.LotName,
|
||||
Updated: updated,
|
||||
Errors: errors,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
offset += limit
|
||||
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
||||
manual++
|
||||
reportProgress()
|
||||
continue
|
||||
}
|
||||
|
||||
method := comp.PriceMethod
|
||||
if method == "" {
|
||||
method = models.PriceMethodMedian
|
||||
}
|
||||
|
||||
var sourceLots []string
|
||||
if comp.MetaPrices != "" {
|
||||
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
|
||||
} else {
|
||||
sourceLots = []string{comp.LotName}
|
||||
}
|
||||
if len(sourceLots) == 0 {
|
||||
skipped++
|
||||
reportProgress()
|
||||
continue
|
||||
}
|
||||
|
||||
if comp.PriceUpdatedAt != nil {
|
||||
hasNewData := false
|
||||
for _, lot := range sourceLots {
|
||||
if latestDate, ok := lotLatestDate[lot]; ok && latestDate.After(*comp.PriceUpdatedAt) {
|
||||
hasNewData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNewData {
|
||||
unchanged++
|
||||
reportProgress()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var prices []float64
|
||||
if comp.PricePeriodDays > 0 {
|
||||
_ = s.db.Raw(
|
||||
`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||
sourceLots, comp.PricePeriodDays,
|
||||
).Pluck("price", &prices).Error
|
||||
} else {
|
||||
_ = s.db.Raw(
|
||||
`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
|
||||
sourceLots,
|
||||
).Pluck("price", &prices).Error
|
||||
}
|
||||
|
||||
if len(prices) == 0 && comp.PricePeriodDays > 0 {
|
||||
_ = s.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices).Error
|
||||
}
|
||||
if len(prices) == 0 {
|
||||
skipped++
|
||||
reportProgress()
|
||||
continue
|
||||
}
|
||||
|
||||
var basePrice float64
|
||||
switch method {
|
||||
case models.PriceMethodAverage:
|
||||
basePrice = CalculateAverage(prices)
|
||||
default:
|
||||
basePrice = CalculateMedian(prices)
|
||||
}
|
||||
if basePrice <= 0 {
|
||||
skipped++
|
||||
reportProgress()
|
||||
continue
|
||||
}
|
||||
|
||||
finalPrice := basePrice
|
||||
if comp.PriceCoefficient != 0 {
|
||||
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
||||
}
|
||||
|
||||
if err := s.db.Model(&models.LotMetadata{}).
|
||||
Where("lot_name = ?", comp.LotName).
|
||||
Updates(map[string]interface{}{
|
||||
"current_price": finalPrice,
|
||||
"price_updated_at": now,
|
||||
}).Error; err != nil {
|
||||
errors++
|
||||
} else {
|
||||
updated++
|
||||
}
|
||||
|
||||
reportProgress()
|
||||
}
|
||||
|
||||
if onProgress != nil && total == 0 {
|
||||
onProgress(RecalculateProgress{
|
||||
Current: 0,
|
||||
Total: 0,
|
||||
LotName: "",
|
||||
Updated: updated,
|
||||
Errors: errors,
|
||||
})
|
||||
}
|
||||
|
||||
return updated, errors
|
||||
}
|
||||
|
||||
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
|
||||
sources := strings.Split(metaPrices, ",")
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, source := range sources {
|
||||
source = strings.TrimSpace(source)
|
||||
if source == "" || source == excludeLot {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(source, "*") {
|
||||
prefix := strings.TrimSuffix(source, "*")
|
||||
for _, lot := range allLotNames {
|
||||
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
|
||||
result = append(result, lot)
|
||||
seen[lot] = true
|
||||
}
|
||||
}
|
||||
} else if !seen[source] {
|
||||
result = append(result, source)
|
||||
seen[source] = true
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
296
internal/services/project.go
Normal file
296
internal/services/project.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
)
|
||||
|
||||
type ProjectService struct {
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
||||
return &ProjectService{localDB: localDB}
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ProjectConfigurationsResult struct {
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
Configs []models.Configuration `json:"configurations"`
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
|
||||
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("project name is required")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
localProject := &localdb.LocalProject{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: name,
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
SyncStatus: "pending",
|
||||
}
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enqueueProjectPendingChange(localProject, "create"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdateProjectRequest) (*models.Project, error) {
|
||||
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if localProject.OwnerUsername != ownerUsername {
|
||||
return nil, ErrProjectForbidden
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("project name is required")
|
||||
}
|
||||
|
||||
localProject.Name = name
|
||||
localProject.UpdatedAt = time.Now()
|
||||
localProject.SyncStatus = "pending"
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enqueueProjectPendingChange(localProject, "update"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
||||
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||
}
|
||||
|
||||
func (s *ProjectService) Reactivate(projectUUID, ownerUsername string) error {
|
||||
return s.setProjectActive(projectUUID, ownerUsername, true)
|
||||
}
|
||||
|
||||
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var project localdb.LocalProject
|
||||
if err := tx.Where("uuid = ?", projectUUID).First(&project).Error; err != nil {
|
||||
return ErrProjectNotFound
|
||||
}
|
||||
if project.OwnerUsername != ownerUsername {
|
||||
return ErrProjectForbidden
|
||||
}
|
||||
if project.IsActive == isActive {
|
||||
return nil
|
||||
}
|
||||
|
||||
project.IsActive = isActive
|
||||
project.UpdatedAt = time.Now()
|
||||
project.SyncStatus = "pending"
|
||||
if err := tx.Save(&project).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.enqueueProjectPendingChangeTx(tx, &project, boolToOp(isActive, "reactivate", "archive")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var configs []localdb.LocalConfiguration
|
||||
if err := tx.Where("project_uuid = ?", projectUUID).Find(&configs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range configs {
|
||||
cfg := configs[i]
|
||||
cfg.IsActive = isActive
|
||||
cfg.SyncStatus = "pending"
|
||||
cfg.UpdatedAt = time.Now()
|
||||
if err := tx.Save(&cfg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
modelCfg := localdb.LocalToConfiguration(&cfg)
|
||||
payload, err := json.Marshal(modelCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
change := &localdb.PendingChange{
|
||||
EntityType: "configuration",
|
||||
EntityUUID: cfg.UUID,
|
||||
Operation: "update",
|
||||
Payload: string(payload),
|
||||
CreatedAt: time.Now(),
|
||||
Attempts: 0,
|
||||
}
|
||||
if err := tx.Create(change).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ProjectService) ListByUser(ownerUsername string, includeArchived bool) ([]models.Project, error) {
|
||||
localProjects, err := s.localDB.GetAllProjects(includeArchived)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
projects := make([]models.Project, 0, len(localProjects))
|
||||
for i := range localProjects {
|
||||
projects = append(projects, *localdb.LocalToProject(&localProjects[i]))
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) GetByUUID(projectUUID, ownerUsername string) (*models.Project, error) {
|
||||
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
|
||||
project, err := s.GetByUUID(projectUUID, ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !project.IsActive && status == "active" {
|
||||
return &ProjectConfigurationsResult{
|
||||
ProjectUUID: projectUUID,
|
||||
Configs: []models.Configuration{},
|
||||
Total: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
localConfigs, err := s.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configs := make([]models.Configuration, 0, len(localConfigs))
|
||||
total := 0.0
|
||||
for i := range localConfigs {
|
||||
localCfg := localConfigs[i]
|
||||
if localCfg.ProjectUUID == nil || *localCfg.ProjectUUID != projectUUID {
|
||||
continue
|
||||
}
|
||||
switch status {
|
||||
case "active", "":
|
||||
if !localCfg.IsActive {
|
||||
continue
|
||||
}
|
||||
case "archived":
|
||||
if localCfg.IsActive {
|
||||
continue
|
||||
}
|
||||
case "all":
|
||||
default:
|
||||
if !localCfg.IsActive {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
cfg := localdb.LocalToConfiguration(&localCfg)
|
||||
if cfg.TotalPrice != nil {
|
||||
total += *cfg.TotalPrice
|
||||
}
|
||||
configs = append(configs, *cfg)
|
||||
}
|
||||
|
||||
return &ProjectConfigurationsResult{
|
||||
ProjectUUID: projectUUID,
|
||||
Configs: configs,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) ResolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
|
||||
if projectUUID == nil || strings.TrimSpace(*projectUUID) == "" {
|
||||
project, err := s.localDB.EnsureDefaultProject(ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project.UUID, nil
|
||||
}
|
||||
|
||||
project, err := s.localDB.GetProjectByUUID(strings.TrimSpace(*projectUUID))
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if project.OwnerUsername != ownerUsername {
|
||||
return nil, ErrProjectForbidden
|
||||
}
|
||||
if !project.IsActive {
|
||||
return nil, fmt.Errorf("project is archived")
|
||||
}
|
||||
|
||||
resolved := project.UUID
|
||||
return &resolved, nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) enqueueProjectPendingChange(project *localdb.LocalProject, operation string) error {
|
||||
return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation)
|
||||
}
|
||||
|
||||
func (s *ProjectService) enqueueProjectPendingChangeTx(tx *gorm.DB, project *localdb.LocalProject, operation string) error {
|
||||
payload := sync.ProjectChangePayload{
|
||||
EventID: uuid.NewString(),
|
||||
ProjectUUID: project.UUID,
|
||||
Operation: operation,
|
||||
Snapshot: *localdb.LocalToProject(project),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
IdempotencyKey: fmt.Sprintf("%s:%d:%s", project.UUID, project.UpdatedAt.UnixNano(), operation),
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
change := &localdb.PendingChange{
|
||||
EntityType: "project",
|
||||
EntityUUID: project.UUID,
|
||||
Operation: operation,
|
||||
Payload: string(raw),
|
||||
CreatedAt: time.Now(),
|
||||
Attempts: 0,
|
||||
}
|
||||
return tx.Create(change).Error
|
||||
}
|
||||
|
||||
func boolToOp(v bool, whenTrue, whenFalse string) string {
|
||||
if v {
|
||||
return whenTrue
|
||||
}
|
||||
return whenFalse
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -18,8 +20,9 @@ var ErrOffline = errors.New("database is offline")
|
||||
|
||||
// Service handles synchronization between MariaDB and local SQLite
|
||||
type Service struct {
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
directDB *gorm.DB
|
||||
}
|
||||
|
||||
// NewService creates a new sync service
|
||||
@@ -30,6 +33,14 @@ func NewService(connMgr *db.ConnectionManager, localDB *localdb.LocalDB) *Servic
|
||||
}
|
||||
}
|
||||
|
||||
// NewServiceWithDB creates sync service that uses a direct DB handle (used in tests).
|
||||
func NewServiceWithDB(mariaDB *gorm.DB, localDB *localdb.LocalDB) *Service {
|
||||
return &Service{
|
||||
localDB: localDB,
|
||||
directDB: mariaDB,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncStatus represents the current sync status
|
||||
type SyncStatus struct {
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
@@ -51,6 +62,8 @@ type ConfigurationChangePayload struct {
|
||||
EventID string `json:"event_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
ConfigurationUUID string `json:"configuration_uuid"`
|
||||
ProjectUUID *string `json:"project_uuid,omitempty"`
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete
|
||||
CurrentVersionID string `json:"current_version_id,omitempty"`
|
||||
CurrentVersionNo int `json:"current_version_no,omitempty"`
|
||||
@@ -60,10 +73,19 @@ type ConfigurationChangePayload struct {
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
type ProjectChangePayload struct {
|
||||
EventID string `json:"event_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
Operation string `json:"operation"`
|
||||
Snapshot models.Project `json:"snapshot"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ImportConfigurationsToLocal imports configurations from MariaDB into local SQLite.
|
||||
// Existing local configs with pending local changes are skipped to avoid data loss.
|
||||
func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return nil, ErrOffline
|
||||
}
|
||||
@@ -129,9 +151,9 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
|
||||
// Count server pricelists (only if already connected, don't reconnect)
|
||||
serverCount := 0
|
||||
connStatus := s.connMgr.GetStatus()
|
||||
connStatus := s.getConnectionStatus()
|
||||
if connStatus.IsConnected {
|
||||
if mariaDB, err := s.connMgr.GetDB(); err == nil && mariaDB != nil {
|
||||
if mariaDB, err := s.getDB(); err == nil && mariaDB != nil {
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
activeCount, err := pricelistRepo.CountActive()
|
||||
if err == nil {
|
||||
@@ -169,13 +191,13 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
}
|
||||
|
||||
// Check if there are new pricelists on server (only if already connected)
|
||||
connStatus := s.connMgr.GetStatus()
|
||||
connStatus := s.getConnectionStatus()
|
||||
if !connStatus.IsConnected {
|
||||
// If offline, can't check server, no need to sync
|
||||
return false, nil
|
||||
}
|
||||
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
// If offline, can't check server, no need to sync
|
||||
return false, nil
|
||||
@@ -207,7 +229,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Info("starting pricelist sync")
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
@@ -300,7 +322,7 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
@@ -397,6 +419,13 @@ func (s *Service) SyncPricelistsIfNeeded() error {
|
||||
|
||||
// PushPendingChanges pushes all pending changes to the server
|
||||
func (s *Service) PushPendingChanges() (int, error) {
|
||||
removed, err := s.localDB.PurgeOrphanConfigurationPendingChanges()
|
||||
if err != nil {
|
||||
slog.Warn("failed to purge orphan configuration pending changes", "error", err)
|
||||
} else if removed > 0 {
|
||||
slog.Info("purged orphan configuration pending changes", "removed", removed)
|
||||
}
|
||||
|
||||
changes, err := s.localDB.GetPendingChanges()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting pending changes: %w", err)
|
||||
@@ -410,8 +439,9 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
slog.Info("pushing pending changes", "count", len(changes))
|
||||
pushed := 0
|
||||
var syncedIDs []int64
|
||||
sortedChanges := prioritizeProjectChanges(changes)
|
||||
|
||||
for _, change := range changes {
|
||||
for _, change := range sortedChanges {
|
||||
err := s.pushSingleChange(&change)
|
||||
if err != nil {
|
||||
slog.Warn("failed to push change", "id", change.ID, "type", change.EntityType, "operation", change.Operation, "error", err)
|
||||
@@ -438,6 +468,8 @@ func (s *Service) PushPendingChanges() (int, error) {
|
||||
// pushSingleChange pushes a single pending change to the server
|
||||
func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
|
||||
switch change.EntityType {
|
||||
case "project":
|
||||
return s.pushProjectChange(change)
|
||||
case "configuration":
|
||||
return s.pushConfigurationChange(change)
|
||||
default:
|
||||
@@ -445,6 +477,95 @@ func (s *Service) pushSingleChange(change *localdb.PendingChange) error {
|
||||
}
|
||||
}
|
||||
|
||||
func prioritizeProjectChanges(changes []localdb.PendingChange) []localdb.PendingChange {
|
||||
if len(changes) < 2 {
|
||||
return changes
|
||||
}
|
||||
|
||||
projectChanges := make([]localdb.PendingChange, 0, len(changes))
|
||||
otherChanges := make([]localdb.PendingChange, 0, len(changes))
|
||||
for _, change := range changes {
|
||||
if change.EntityType == "project" {
|
||||
projectChanges = append(projectChanges, change)
|
||||
continue
|
||||
}
|
||||
otherChanges = append(otherChanges, change)
|
||||
}
|
||||
|
||||
sorted := make([]localdb.PendingChange, 0, len(changes))
|
||||
sorted = append(sorted, projectChanges...)
|
||||
sorted = append(sorted, otherChanges...)
|
||||
return sorted
|
||||
}
|
||||
|
||||
func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
payload, err := decodeProjectChangePayload(change)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode project payload: %w", err)
|
||||
}
|
||||
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||
project := payload.Snapshot
|
||||
project.UUID = payload.ProjectUUID
|
||||
|
||||
serverProject, err := projectRepo.GetByUUID(project.UUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if createErr := projectRepo.Create(&project); createErr != nil {
|
||||
return fmt.Errorf("create project on server: %w", createErr)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("get project on server: %w", err)
|
||||
}
|
||||
} else {
|
||||
project.ID = serverProject.ID
|
||||
if updateErr := projectRepo.Update(&project); updateErr != nil {
|
||||
return fmt.Errorf("update project on server: %w", updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||
if localErr == nil {
|
||||
if project.ID > 0 {
|
||||
serverID := project.ID
|
||||
localProject.ServerID = &serverID
|
||||
}
|
||||
localProject.SyncStatus = "synced"
|
||||
now := time.Now()
|
||||
localProject.SyncedAt = &now
|
||||
_ = s.localDB.SaveProject(localProject)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
|
||||
var payload ProjectChangePayload
|
||||
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
|
||||
if payload.Operation == "" {
|
||||
payload.Operation = change.Operation
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
var project models.Project
|
||||
if err := json.Unmarshal([]byte(change.Payload), &project); err != nil {
|
||||
return ProjectChangePayload{}, fmt.Errorf("unmarshal legacy project payload: %w", err)
|
||||
}
|
||||
|
||||
return ProjectChangePayload{
|
||||
ProjectUUID: project.UUID,
|
||||
Operation: change.Operation,
|
||||
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", project.UUID, change.Operation),
|
||||
Snapshot: project,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// pushConfigurationChange pushes a configuration change to the server
|
||||
func (s *Service) pushConfigurationChange(change *localdb.PendingChange) error {
|
||||
switch change.Operation {
|
||||
@@ -477,7 +598,7 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
@@ -487,6 +608,12 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration owner: %w", err)
|
||||
}
|
||||
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration project: %w", err)
|
||||
}
|
||||
if err := s.ensureConfigurationPricelist(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Create on server
|
||||
if err := configRepo.Create(&cfg); err != nil {
|
||||
@@ -532,7 +659,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
@@ -542,6 +669,12 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
if err := s.ensureConfigurationOwner(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration owner: %w", err)
|
||||
}
|
||||
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration project: %w", err)
|
||||
}
|
||||
if err := s.ensureConfigurationPricelist(mariaDB, &cfg); err != nil {
|
||||
return fmt.Errorf("resolve configuration pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Ensure we have a server ID before updating
|
||||
// If the payload doesn't have ID, get it from local configuration
|
||||
@@ -605,15 +738,96 @@ func (s *Service) ensureConfigurationOwner(mariaDB *gorm.DB, cfg *models.Configu
|
||||
return fmt.Errorf("owner username is empty")
|
||||
}
|
||||
|
||||
userID, err := models.EnsureDBUser(mariaDB, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if userID == 0 {
|
||||
return fmt.Errorf("resolved user ID is 0 for owner %q", ownerUsername)
|
||||
// user_id is legacy and no longer used for ownership in local-first mode.
|
||||
// Keep it NULL on writes; ownership is represented by owner_username.
|
||||
cfg.UserID = nil
|
||||
cfg.AppVersion = appmeta.Version()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Configuration) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("configuration is nil")
|
||||
}
|
||||
|
||||
cfg.UserID = userID
|
||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||
|
||||
if cfg.ProjectUUID != nil && *cfg.ProjectUUID != "" {
|
||||
_, err := projectRepo.GetByUUID(*cfg.ProjectUUID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
localProject, localErr := s.localDB.GetProjectByUUID(*cfg.ProjectUUID)
|
||||
if localErr != nil {
|
||||
return err
|
||||
}
|
||||
modelProject := localdb.LocalToProject(localProject)
|
||||
if modelProject.OwnerUsername == "" {
|
||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||
}
|
||||
if createErr := projectRepo.Create(modelProject); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if modelProject.ID > 0 {
|
||||
serverID := modelProject.ID
|
||||
localProject.ServerID = &serverID
|
||||
localProject.SyncStatus = "synced"
|
||||
now := time.Now()
|
||||
localProject.SyncedAt = &now
|
||||
_ = s.localDB.SaveProject(localProject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
systemProject := &models.Project{}
|
||||
err := mariaDB.
|
||||
Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND is_system = ?", "Без проекта", true).
|
||||
Order("CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC").
|
||||
First(systemProject).Error
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
systemProject = &models.Project{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
}
|
||||
if createErr := projectRepo.Create(systemProject); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
}
|
||||
|
||||
cfg.ProjectUUID = &systemProject.UUID
|
||||
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
|
||||
}
|
||||
|
||||
@@ -641,6 +855,10 @@ func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChan
|
||||
|
||||
currentCfg, currentVersionID, currentVersionNo, err := s.loadCurrentConfigurationState(payload.ConfigurationUUID)
|
||||
if err != nil {
|
||||
// Local config may be gone (e.g. stale queue item after delete/cleanup). Treat as no-op.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return payload, payload.Snapshot, true, nil
|
||||
}
|
||||
// create->deactivate race: config may no longer be active/visible locally, skip stale create.
|
||||
if change.Operation == "create" {
|
||||
return payload, payload.Snapshot, true, nil
|
||||
@@ -660,6 +878,7 @@ func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChan
|
||||
if currentVersionNo > 0 {
|
||||
payload.CurrentVersionNo = currentVersionNo
|
||||
}
|
||||
payload.PricelistID = currentCfg.PricelistID
|
||||
}
|
||||
|
||||
isStale := false
|
||||
@@ -696,6 +915,8 @@ func decodeConfigurationChangePayload(change *localdb.PendingChange) (Configurat
|
||||
EventID: "",
|
||||
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation),
|
||||
ConfigurationUUID: cfg.UUID,
|
||||
ProjectUUID: cfg.ProjectUUID,
|
||||
PricelistID: cfg.PricelistID,
|
||||
Operation: change.Operation,
|
||||
ConflictPolicy: "last_write_wins",
|
||||
Snapshot: cfg,
|
||||
@@ -752,7 +973,7 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
||||
// pushConfigurationDelete deletes a configuration from the server
|
||||
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
@@ -776,3 +997,23 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
slog.Info("configuration deleted on server", "uuid", change.EntityUUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) getDB() (*gorm.DB, error) {
|
||||
if s.directDB != nil {
|
||||
return s.directDB, nil
|
||||
}
|
||||
if s.connMgr == nil {
|
||||
return nil, ErrOffline
|
||||
}
|
||||
return s.connMgr.GetDB()
|
||||
}
|
||||
|
||||
func (s *Service) getConnectionStatus() db.ConnectionStatus {
|
||||
if s.directDB != nil {
|
||||
return db.ConnectionStatus{IsConnected: true}
|
||||
}
|
||||
if s.connMgr == nil {
|
||||
return db.ConnectionStatus{IsConnected: false}
|
||||
}
|
||||
return s.connMgr.GetStatus()
|
||||
}
|
||||
|
||||
25
internal/services/sync/service_order_test.go
Normal file
25
internal/services/sync/service_order_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
)
|
||||
|
||||
func TestPrioritizeProjectChanges(t *testing.T) {
|
||||
changes := []localdb.PendingChange{
|
||||
{ID: 1, EntityType: "configuration"},
|
||||
{ID: 2, EntityType: "project"},
|
||||
{ID: 3, EntityType: "configuration"},
|
||||
{ID: 4, EntityType: "project"},
|
||||
}
|
||||
|
||||
sorted := prioritizeProjectChanges(changes)
|
||||
if len(sorted) != 4 {
|
||||
t.Fatalf("unexpected sorted length: %d", len(sorted))
|
||||
}
|
||||
|
||||
if sorted[0].EntityType != "project" || sorted[1].EntityType != "project" {
|
||||
t.Fatalf("expected project changes first, got order: %s, %s", sorted[0].EntityType, sorted[1].EntityType)
|
||||
}
|
||||
}
|
||||
274
internal/services/sync/service_projects_push_test.go
Normal file
274
internal/services/sync/service_projects_push_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package sync_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestPushPendingChangesProjectsBeforeConfigurations(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 })
|
||||
|
||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project A"})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := configService.Create("tester", &services.CreateConfigRequest{
|
||||
Name: "Cfg A",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
ProjectUUID: &project.UUID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
pushed, err := pushService.PushPendingChanges()
|
||||
if err != nil {
|
||||
t.Fatalf("push pending changes: %v", err)
|
||||
}
|
||||
if pushed < 2 {
|
||||
t.Fatalf("expected at least 2 pushed changes, got %d", pushed)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if got := local.CountPendingChanges(); got != 0 {
|
||||
t.Fatalf("expected pending queue to be empty, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(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_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if _, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("initial push: %v", err)
|
||||
}
|
||||
|
||||
if _, err := configService.UpdateNoAuth(created.UUID, &services.CreateConfigRequest{
|
||||
Name: "Cfg v2",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
ProjectUUID: created.ProjectUUID,
|
||||
}); err != nil {
|
||||
t.Fatalf("update config: %v", err)
|
||||
}
|
||||
|
||||
localCfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("get local config: %v", err)
|
||||
}
|
||||
cfgSnapshot := localdb.LocalToConfiguration(localCfg)
|
||||
stalePayload := syncsvc.ConfigurationChangePayload{
|
||||
EventID: "stale-event",
|
||||
IdempotencyKey: fmt.Sprintf("%s:v1:update", created.UUID),
|
||||
ConfigurationUUID: created.UUID,
|
||||
ProjectUUID: cfgSnapshot.ProjectUUID,
|
||||
Operation: "update",
|
||||
CurrentVersionID: "stale-v1",
|
||||
CurrentVersionNo: 1,
|
||||
ConflictPolicy: "last_write_wins",
|
||||
Snapshot: *cfgSnapshot,
|
||||
CreatedAt: time.Now().UTC().Add(-2 * time.Second),
|
||||
}
|
||||
raw, err := json.Marshal(stalePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal stale payload: %v", err)
|
||||
}
|
||||
if err := local.DB().Create(&localdb.PendingChange{
|
||||
EntityType: "configuration",
|
||||
EntityUUID: created.UUID,
|
||||
Operation: "update",
|
||||
Payload: string(raw),
|
||||
CreatedAt: time.Now().Add(-1 * time.Second),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert stale pending change: %v", err)
|
||||
}
|
||||
|
||||
if _, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("push pending with stale event: %v", err)
|
||||
}
|
||||
|
||||
var serverCfg models.Configuration
|
||||
if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil {
|
||||
t.Fatalf("get server config: %v", err)
|
||||
}
|
||||
if serverCfg.Name != "Cfg v2" {
|
||||
t.Fatalf("expected latest name to win, got %q", serverCfg.Name)
|
||||
}
|
||||
if got := local.CountPendingChanges(); got != 0 {
|
||||
t.Fatalf("expected empty pending queue, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPendingChangesCreateIsIdempotent(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 Idempotent",
|
||||
Items: models.ConfigItems{{LotName: "CPU_B", Quantity: 1, UnitPrice: 500}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if _, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("initial push: %v", err)
|
||||
}
|
||||
|
||||
localCfg, err := local.GetConfigurationByUUID(created.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("get local config: %v", err)
|
||||
}
|
||||
currentVersionNo, currentVersionID := getCurrentVersionInfo(t, local, created.UUID, localCfg.CurrentVersionID)
|
||||
cfgSnapshot := localdb.LocalToConfiguration(localCfg)
|
||||
duplicatePayload := syncsvc.ConfigurationChangePayload{
|
||||
EventID: "duplicate-create-event",
|
||||
IdempotencyKey: fmt.Sprintf("%s:v%d:create", created.UUID, currentVersionNo),
|
||||
ConfigurationUUID: created.UUID,
|
||||
ProjectUUID: cfgSnapshot.ProjectUUID,
|
||||
Operation: "create",
|
||||
CurrentVersionID: currentVersionID,
|
||||
CurrentVersionNo: currentVersionNo,
|
||||
ConflictPolicy: "last_write_wins",
|
||||
Snapshot: *cfgSnapshot,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
raw, err := json.Marshal(duplicatePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal duplicate payload: %v", err)
|
||||
}
|
||||
if err := local.AddPendingChange("configuration", created.UUID, "create", string(raw)); err != nil {
|
||||
t.Fatalf("add duplicate create pending change: %v", err)
|
||||
}
|
||||
|
||||
if pushed, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("push duplicate create: %v", err)
|
||||
} else if pushed != 1 {
|
||||
t.Fatalf("expected 1 pushed change for duplicate create, got %d", pushed)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := serverDB.Model(&models.Configuration{}).Where("uuid = ?", created.UUID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count server configs: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected one server row after idempotent create, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func newLocalDBForSyncTest(t *testing.T) *localdb.LocalDB {
|
||||
t.Helper()
|
||||
localPath := filepath.Join(t.TempDir(), "local.db")
|
||||
local, err := localdb.New(localPath)
|
||||
if err != nil {
|
||||
t.Fatalf("init local db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
return local
|
||||
}
|
||||
|
||||
func newServerDBForSyncTest(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
serverPath := filepath.Join(t.TempDir(), "server.db")
|
||||
db, err := gorm.Open(sqlite.Open(serverPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open server sqlite: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE qt_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
owner_username TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create qt_projects: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE qt_configurations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NULL,
|
||||
owner_username TEXT NOT NULL,
|
||||
project_uuid TEXT NULL,
|
||||
app_version TEXT NULL,
|
||||
name TEXT NOT NULL,
|
||||
items TEXT NOT NULL,
|
||||
total_price REAL NULL,
|
||||
custom_price REAL NULL,
|
||||
notes TEXT NULL,
|
||||
is_template INTEGER NOT NULL DEFAULT 0,
|
||||
server_count INTEGER NOT NULL DEFAULT 1,
|
||||
pricelist_id INTEGER NULL,
|
||||
price_updated_at DATETIME NULL,
|
||||
created_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create qt_configurations: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
||||
t.Helper()
|
||||
if currentVersionID == nil || *currentVersionID == "" {
|
||||
t.Fatalf("current version id is empty for %s", configurationUUID)
|
||||
}
|
||||
|
||||
var version localdb.LocalConfigurationVersion
|
||||
if err := local.DB().
|
||||
Where("id = ? AND configuration_uuid = ?", *currentVersionID, configurationUUID).
|
||||
First(&version).Error; err != nil {
|
||||
t.Fatalf("get current version info: %v", err)
|
||||
}
|
||||
|
||||
return version.VersionNo, version.ID
|
||||
}
|
||||
@@ -14,6 +14,7 @@ CREATE TABLE local_configuration_versions (
|
||||
data TEXT NOT NULL,
|
||||
change_note TEXT NULL,
|
||||
created_by TEXT NULL,
|
||||
app_version TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (configuration_uuid) REFERENCES local_configurations(uuid),
|
||||
UNIQUE(configuration_uuid, version_no)
|
||||
@@ -36,6 +37,7 @@ INSERT INTO local_configuration_versions (
|
||||
data,
|
||||
change_note,
|
||||
created_by,
|
||||
app_version,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
@@ -58,10 +60,12 @@ SELECT
|
||||
'synced_at', synced_at,
|
||||
'sync_status', sync_status,
|
||||
'original_user_id', original_user_id,
|
||||
'original_username', original_username
|
||||
'original_username', original_username,
|
||||
'app_version', NULL
|
||||
) AS data,
|
||||
'Initial snapshot backfill (v1)' AS change_note,
|
||||
NULL AS created_by,
|
||||
NULL AS app_version,
|
||||
COALESCE(created_at, CURRENT_TIMESTAMP) AS created_at
|
||||
FROM local_configurations;
|
||||
|
||||
|
||||
25
migrations/007_detach_configurations_from_qt_users.sql
Normal file
25
migrations/007_detach_configurations_from_qt_users.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Detach qt_configurations from qt_users (ownership is owner_username text)
|
||||
-- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks.
|
||||
|
||||
SET @fk_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'qt_configurations'
|
||||
AND CONSTRAINT_NAME = 'fk_qt_configurations_user'
|
||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
);
|
||||
|
||||
SET @drop_fk_sql := IF(
|
||||
@fk_exists > 0,
|
||||
'ALTER TABLE qt_configurations DROP FOREIGN KEY fk_qt_configurations_user',
|
||||
'SELECT ''fk_qt_configurations_user not found, skip'' '
|
||||
);
|
||||
PREPARE stmt_drop_fk FROM @drop_fk_sql;
|
||||
EXECUTE stmt_drop_fk;
|
||||
DEALLOCATE PREPARE stmt_drop_fk;
|
||||
|
||||
-- user_id becomes optional legacy column (can stay NULL)
|
||||
ALTER TABLE qt_configurations
|
||||
MODIFY COLUMN user_id BIGINT UNSIGNED NULL;
|
||||
|
||||
4
migrations/008_add_app_version_to_configurations.sql
Normal file
4
migrations/008_add_app_version_to_configurations.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Track application version used for configuration writes (create/update via sync)
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username;
|
||||
|
||||
45
migrations/009_add_projects.sql
Normal file
45
migrations/009_add_projects.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- Add projects and attach configurations to projects
|
||||
|
||||
CREATE TABLE qt_projects (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid CHAR(36) NOT NULL UNIQUE,
|
||||
owner_username VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_qt_projects_owner_username (owner_username),
|
||||
INDEX idx_qt_projects_is_active (is_active),
|
||||
INDEX idx_qt_projects_is_system (is_system)
|
||||
);
|
||||
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN project_uuid CHAR(36) NULL AFTER app_version,
|
||||
ADD INDEX idx_qt_configurations_project_uuid (project_uuid),
|
||||
ADD CONSTRAINT fk_qt_configurations_project_uuid
|
||||
FOREIGN KEY (project_uuid) REFERENCES qt_projects(uuid)
|
||||
ON UPDATE CASCADE
|
||||
ON DELETE SET NULL;
|
||||
|
||||
-- One system project per owner: "Без проекта"
|
||||
INSERT INTO qt_projects (uuid, owner_username, name, is_active, is_system, created_at, updated_at)
|
||||
SELECT UUID(), owners.owner_username, 'Без проекта', TRUE, TRUE, NOW(), NOW()
|
||||
FROM (
|
||||
SELECT DISTINCT owner_username
|
||||
FROM qt_configurations
|
||||
) AS owners
|
||||
LEFT JOIN qt_projects p
|
||||
ON p.owner_username = owners.owner_username
|
||||
AND p.name = 'Без проекта'
|
||||
AND p.is_system = TRUE
|
||||
WHERE p.id IS NULL;
|
||||
|
||||
-- Attach all existing configurations without project to the system project
|
||||
UPDATE qt_configurations c
|
||||
JOIN qt_projects p
|
||||
ON p.owner_username = c.owner_username
|
||||
AND p.name = 'Без проекта'
|
||||
AND p.is_system = TRUE
|
||||
SET c.project_uuid = p.uuid
|
||||
WHERE c.project_uuid IS NULL OR c.project_uuid = '';
|
||||
37
migrations/010_add_pricelist_to_configurations.sql
Normal file
37
migrations/010_add_pricelist_to_configurations.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- Add pricelist binding to configurations
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN pricelist_id BIGINT UNSIGNED NULL AFTER server_count;
|
||||
|
||||
ALTER TABLE qt_configurations
|
||||
ADD INDEX idx_qt_configurations_pricelist_id (pricelist_id),
|
||||
ADD CONSTRAINT fk_qt_configurations_pricelist_id
|
||||
FOREIGN KEY (pricelist_id)
|
||||
REFERENCES qt_pricelists(id)
|
||||
ON DELETE RESTRICT
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill existing configurations to latest active pricelist
|
||||
SET @latest_active_pricelist_id := (
|
||||
SELECT id
|
||||
FROM qt_pricelists
|
||||
WHERE is_active = 1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
UPDATE qt_configurations
|
||||
SET pricelist_id = @latest_active_pricelist_id
|
||||
WHERE pricelist_id IS NULL
|
||||
AND @latest_active_pricelist_id IS NOT NULL;
|
||||
|
||||
-- Recalculate usage_count from configuration bindings
|
||||
UPDATE qt_pricelists SET usage_count = 0;
|
||||
|
||||
UPDATE qt_pricelists pl
|
||||
JOIN (
|
||||
SELECT pricelist_id, COUNT(*) AS cnt
|
||||
FROM qt_configurations
|
||||
WHERE pricelist_id IS NOT NULL
|
||||
GROUP BY pricelist_id
|
||||
) cfg ON cfg.pricelist_id = pl.id
|
||||
SET pl.usage_count = cfg.cnt;
|
||||
@@ -13,14 +13,14 @@
|
||||
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
|
||||
</div>
|
||||
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
|
||||
Пересчитать цены
|
||||
Обновить цены
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
|
||||
<div class="flex justify-between text-sm text-gray-700 mb-2">
|
||||
<span id="progress-text" class="font-medium">Пересчёт цен...</span>
|
||||
<span id="progress-text" class="font-medium">Обновление цен...</span>
|
||||
<span id="progress-percent" class="font-bold">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-4">
|
||||
@@ -94,6 +94,16 @@
|
||||
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
|
||||
</p>
|
||||
<form id="pricelists-create-form" class="space-y-4">
|
||||
<div id="pricelist-create-progress" class="hidden p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div class="flex justify-between items-center text-sm mb-2">
|
||||
<span id="pricelist-create-progress-text" class="font-medium">Подготовка...</span>
|
||||
<span id="pricelist-create-progress-percent" class="font-bold">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-blue-100 rounded-full h-3 overflow-hidden">
|
||||
<div id="pricelist-create-progress-bar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<div id="pricelist-create-progress-stats" class="text-xs text-gray-600 mt-2"></div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="closePricelistsCreateModal()"
|
||||
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
@@ -750,11 +760,11 @@ function recalculateAll() {
|
||||
|
||||
// Show progress bar IMMEDIATELY
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Пересчёт...';
|
||||
btn.textContent = 'Обновление...';
|
||||
progressContainer.style.display = 'block';
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
|
||||
progressText.textContent = 'Запуск пересчёта...';
|
||||
progressText.textContent = 'Запуск обновления...';
|
||||
progressPercent.textContent = '0%';
|
||||
progressStats.textContent = 'Подготовка...';
|
||||
|
||||
@@ -769,7 +779,7 @@ function recalculateAll() {
|
||||
reader.read().then(({done, value}) => {
|
||||
if (done) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Пересчитать цены';
|
||||
btn.textContent = 'Обновить цены';
|
||||
progressText.textContent = 'Готово!';
|
||||
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
||||
setTimeout(() => {
|
||||
@@ -794,7 +804,7 @@ function recalculateAll() {
|
||||
progressPercent.textContent = percent + '%';
|
||||
|
||||
if (data.status === 'completed') {
|
||||
progressText.textContent = 'Пересчёт завершён!';
|
||||
progressText.textContent = 'Обновление завершено!';
|
||||
progressBar.className = 'bg-green-600 h-4 rounded-full';
|
||||
} else {
|
||||
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
|
||||
@@ -816,7 +826,7 @@ function recalculateAll() {
|
||||
console.error('Fetch error:', e);
|
||||
alert('Ошибка соединения');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Пересчитать цены';
|
||||
btn.textContent = 'Обновить цены';
|
||||
progressContainer.style.display = 'none';
|
||||
});
|
||||
}
|
||||
@@ -965,6 +975,10 @@ function renderPricelists(pricelists) {
|
||||
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
|
||||
|
||||
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
|
||||
if (pricelistsCanWrite) {
|
||||
const toggleLabel = pl.is_active ? 'Деактивировать' : 'Активировать';
|
||||
actions += ` <button onclick="togglePricelistActive(${pl.id}, ${pl.is_active ? 'false' : 'true'})" class="text-indigo-600 hover:text-indigo-800 text-sm ml-2">${toggleLabel}</button>`;
|
||||
}
|
||||
if (pricelistsCanWrite && pl.usage_count === 0) {
|
||||
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
|
||||
}
|
||||
@@ -989,6 +1003,33 @@ function renderPricelists(pricelists) {
|
||||
document.getElementById('pricelists-body').innerHTML = html;
|
||||
}
|
||||
|
||||
async function togglePricelistActive(id, isActive) {
|
||||
// Check if online before toggling
|
||||
const isOnline = await checkOnlineStatus();
|
||||
if (!isOnline) {
|
||||
showToast('Изменение статуса прайслиста доступно только в онлайн режиме', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/pricelists/${id}/active`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_active: isActive })
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json();
|
||||
throw new Error(data.error || 'Failed to update status');
|
||||
}
|
||||
|
||||
showToast('Статус прайслиста обновлен', 'success');
|
||||
loadPricelists(pricelistsPage);
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderPricelistsPagination(total, page, perPage) {
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
if (totalPages <= 1) {
|
||||
@@ -1024,6 +1065,7 @@ async function loadPricelistsDbUsername() {
|
||||
function openPricelistsCreateModal() {
|
||||
document.getElementById('pricelists-create-modal').classList.remove('hidden');
|
||||
document.getElementById('pricelists-create-modal').classList.add('flex');
|
||||
resetPricelistCreateProgress();
|
||||
loadPricelistsDbUsername();
|
||||
}
|
||||
|
||||
@@ -1049,20 +1091,88 @@ async function createPricelist() {
|
||||
throw new Error('Создание прайслистов доступно только в онлайн режиме');
|
||||
}
|
||||
|
||||
const resp = await fetch('/api/pricelists', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
const progressBox = document.getElementById('pricelist-create-progress');
|
||||
const progressBar = document.getElementById('pricelist-create-progress-bar');
|
||||
const progressText = document.getElementById('pricelist-create-progress-text');
|
||||
const progressPercent = document.getElementById('pricelist-create-progress-percent');
|
||||
const progressStats = document.getElementById('pricelist-create-progress-stats');
|
||||
|
||||
progressBox.classList.remove('hidden');
|
||||
progressBar.style.width = '0%';
|
||||
progressText.textContent = 'Запуск создания прайслиста...';
|
||||
progressPercent.textContent = '0%';
|
||||
progressStats.textContent = '';
|
||||
|
||||
const resp = await fetch('/api/pricelists/create-with-progress', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json();
|
||||
let data = {};
|
||||
try { data = await resp.json(); } catch (_) {}
|
||||
throw new Error(data.error || 'Failed to create pricelist');
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let completedPricelist = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const text = decoder.decode(value);
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(line.slice(5).trim());
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = Number(data.current || 0);
|
||||
const total = Number(data.total || 0);
|
||||
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
progressBar.style.width = percent + '%';
|
||||
progressPercent.textContent = percent + '%';
|
||||
if (data.lot_name) {
|
||||
progressText.textContent = (data.message || 'Обработка') + ': ' + data.lot_name;
|
||||
} else {
|
||||
progressText.textContent = data.message || 'Обработка...';
|
||||
}
|
||||
|
||||
if (data.updated !== undefined || data.errors !== undefined) {
|
||||
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ошибок: ' + (data.errors || 0);
|
||||
}
|
||||
|
||||
if (data.status === 'error') {
|
||||
throw new Error(data.message || 'Ошибка создания прайслиста');
|
||||
}
|
||||
if (data.status === 'completed' && data.pricelist) {
|
||||
completedPricelist = data.pricelist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!completedPricelist) {
|
||||
throw new Error('Создание прервано: не получен результат');
|
||||
}
|
||||
return completedPricelist;
|
||||
}
|
||||
|
||||
function resetPricelistCreateProgress() {
|
||||
const progressBox = document.getElementById('pricelist-create-progress');
|
||||
const progressBar = document.getElementById('pricelist-create-progress-bar');
|
||||
const progressText = document.getElementById('pricelist-create-progress-text');
|
||||
const progressPercent = document.getElementById('pricelist-create-progress-percent');
|
||||
const progressStats = document.getElementById('pricelist-create-progress-stats');
|
||||
progressBox.classList.add('hidden');
|
||||
progressBar.style.width = '0%';
|
||||
progressText.textContent = 'Подготовка...';
|
||||
progressPercent.textContent = '0%';
|
||||
progressStats.textContent = '';
|
||||
}
|
||||
|
||||
async function deletePricelist(id) {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
|
||||
<div class="hidden md:flex space-x-4">
|
||||
<a href="/configurator" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Конфигуратор</a>
|
||||
<a href="/projects" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Мои проекты</a>
|
||||
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Администратор цен</a>
|
||||
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-w-md">
|
||||
<input id="configs-search" type="text" placeholder="Поиск квоты по названию"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
|
||||
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -56,6 +61,13 @@
|
||||
<input type="text" id="opportunity-number" placeholder="Например: OPP-2024-001"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||
<select id="create-project-select"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Без проекта</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
@@ -119,12 +131,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for moving configuration to another project -->
|
||||
<div id="move-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 class="text-sm text-gray-600">
|
||||
Квота: <span id="move-project-config-name" class="font-medium text-gray-900"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||
<input id="move-project-input"
|
||||
list="move-project-options"
|
||||
placeholder="Начните вводить название проекта"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<datalist id="move-project-options"></datalist>
|
||||
<div class="mt-2 flex justify-between items-center gap-3">
|
||||
<button type="button" onclick="clearMoveProjectInput()" class="text-sm text-gray-600 hover:text-gray-800">
|
||||
Без проекта
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="move-project-uuid">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeMoveProjectModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
||||
Отмена
|
||||
</button>
|
||||
<button onclick="confirmMoveProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Перенести
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for creating project during move -->
|
||||
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. Создать и привязать квоту?</p>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Pagination state
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let perPage = 20;
|
||||
let configStatusMode = 'active';
|
||||
let configsSearch = '';
|
||||
let projectsCache = [];
|
||||
let projectNameByUUID = {};
|
||||
let pendingMoveConfigUUID = '';
|
||||
let pendingMoveProjectName = '';
|
||||
|
||||
function renderConfigs(configs) {
|
||||
const emptyText = configStatusMode === 'archived'
|
||||
@@ -139,6 +204,7 @@ function renderConfigs(configs) {
|
||||
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||
html += '<thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Проект</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
||||
@@ -152,6 +218,9 @@ function renderConfigs(configs) {
|
||||
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
||||
const serverCount = c.server_count ? c.server_count : 1;
|
||||
const author = c.owner_username || (c.user && c.user.username) || '—';
|
||||
const projectName = c.project_uuid && projectNameByUUID[c.project_uuid]
|
||||
? projectNameByUUID[c.project_uuid]
|
||||
: 'Без проекта';
|
||||
|
||||
// Calculate price per unit (total / server count)
|
||||
let pricePerUnit = '—';
|
||||
@@ -162,6 +231,19 @@ function renderConfigs(configs) {
|
||||
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
|
||||
if (configStatusMode === 'archived') {
|
||||
if (c.project_uuid) {
|
||||
html += '<td class="px-4 py-3 text-sm"><a href="/projects/' + c.project_uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(projectName) + '</a></td>';
|
||||
} else {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(projectName) + '</td>';
|
||||
}
|
||||
} else {
|
||||
if (c.project_uuid) {
|
||||
html += '<td class="px-4 py-3 text-sm"><a href="/projects/' + c.project_uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(projectName) + '</a></td>';
|
||||
} else {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
|
||||
}
|
||||
}
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
|
||||
} else {
|
||||
@@ -179,6 +261,11 @@ function renderConfigs(configs) {
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
} else {
|
||||
html += '<button onclick="openMoveProjectModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\', \'' + (c.project_uuid || '') + '\')" class="text-indigo-600 hover:text-indigo-800" title="Перенести в проект">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0l-3 3m3-3l3 3m7 1v12m0 0l-3-3m3 3l3-3"></path>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
|
||||
@@ -338,6 +425,8 @@ async function createConfig() {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectUUID = document.getElementById('create-project-select').value;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs', {
|
||||
method: 'POST',
|
||||
@@ -348,7 +437,8 @@ async function createConfig() {
|
||||
name: name,
|
||||
items: [],
|
||||
notes: '',
|
||||
server_count: 1
|
||||
server_count: 1,
|
||||
project_uuid: projectUUID || null
|
||||
})
|
||||
});
|
||||
|
||||
@@ -365,6 +455,129 @@ async function createConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
function openMoveProjectModal(uuid, configName, currentProjectUUID) {
|
||||
document.getElementById('move-project-uuid').value = uuid;
|
||||
document.getElementById('move-project-config-name').textContent = configName;
|
||||
|
||||
const input = document.getElementById('move-project-input');
|
||||
const options = document.getElementById('move-project-options');
|
||||
options.innerHTML = '';
|
||||
projectsCache.forEach(project => {
|
||||
if (!project.is_active) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.name;
|
||||
options.appendChild(option);
|
||||
});
|
||||
|
||||
if (currentProjectUUID && projectNameByUUID[currentProjectUUID]) {
|
||||
input.value = projectNameByUUID[currentProjectUUID];
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
document.getElementById('move-project-modal').classList.remove('hidden');
|
||||
document.getElementById('move-project-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeMoveProjectModal() {
|
||||
document.getElementById('move-project-modal').classList.add('hidden');
|
||||
document.getElementById('move-project-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function confirmMoveProject() {
|
||||
const uuid = document.getElementById('move-project-uuid').value;
|
||||
const projectName = document.getElementById('move-project-input').value.trim();
|
||||
|
||||
if (!uuid) return;
|
||||
let projectUUID = '';
|
||||
|
||||
if (projectName) {
|
||||
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
|
||||
if (existingProject) {
|
||||
projectUUID = existingProject.uuid;
|
||||
} else {
|
||||
pendingMoveConfigUUID = uuid;
|
||||
pendingMoveProjectName = projectName;
|
||||
openCreateProjectOnMoveModal(projectName);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await moveConfigToProject(uuid, projectUUID);
|
||||
}
|
||||
|
||||
function clearMoveProjectInput() {
|
||||
document.getElementById('move-project-input').value = '';
|
||||
}
|
||||
|
||||
function openCreateProjectOnMoveModal(projectName) {
|
||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||
document.getElementById('create-project-on-move-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeCreateProjectOnMoveModal() {
|
||||
document.getElementById('create-project-on-move-modal').classList.add('hidden');
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('flex');
|
||||
pendingMoveConfigUUID = '';
|
||||
pendingMoveProjectName = '';
|
||||
}
|
||||
|
||||
async function confirmCreateProjectOnMove() {
|
||||
const configUUID = pendingMoveConfigUUID;
|
||||
const projectName = pendingMoveProjectName;
|
||||
if (!configUUID || !projectName) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
||||
if (moved) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
closeMoveProjectModal();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка создания проекта');
|
||||
}
|
||||
}
|
||||
|
||||
async function moveConfigToProject(uuid, projectUUID) {
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + uuid + '/project', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ project_uuid: projectUUID })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
alert('Не удалось перенести квоту: ' + (err.error || 'ошибка'));
|
||||
return false;
|
||||
}
|
||||
closeMoveProjectModal();
|
||||
await loadProjectsForConfigUI();
|
||||
await loadConfigs();
|
||||
return true;
|
||||
} catch (e) {
|
||||
alert('Ошибка переноса квоты');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on outside click
|
||||
document.getElementById('create-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
@@ -384,12 +597,26 @@ document.getElementById('clone-modal').addEventListener('click', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('move-project-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeMoveProjectModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-project-on-move-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeCreateModal();
|
||||
closeRenameModal();
|
||||
closeCloneModal();
|
||||
closeMoveProjectModal();
|
||||
closeCreateProjectOnMoveModal();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -461,7 +688,7 @@ function applyStatusModeUI() {
|
||||
// Load configs with pagination
|
||||
async function loadConfigs() {
|
||||
try {
|
||||
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage + '&status=' + configStatusMode);
|
||||
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage + '&status=' + configStatusMode + '&search=' + encodeURIComponent(configsSearch));
|
||||
|
||||
if (!resp.ok) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
@@ -512,12 +739,44 @@ async function importConfigsFromServer() {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
applyStatusModeUI();
|
||||
loadConfigs();
|
||||
loadProjectsForConfigUI().then(loadConfigs);
|
||||
|
||||
// Load latest pricelist version for badge
|
||||
loadLatestPricelistVersion();
|
||||
});
|
||||
|
||||
document.getElementById('configs-search').addEventListener('input', function(e) {
|
||||
configsSearch = (e.target.value || '').trim();
|
||||
currentPage = 1;
|
||||
loadConfigs();
|
||||
});
|
||||
|
||||
async function loadProjectsForConfigUI() {
|
||||
projectsCache = [];
|
||||
projectNameByUUID = {};
|
||||
try {
|
||||
const resp = await fetch('/api/projects?status=all');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
projectsCache = (data.projects || []);
|
||||
|
||||
const select = document.getElementById('create-project-select');
|
||||
if (select) {
|
||||
select.innerHTML = '<option value="">Без проекта</option>';
|
||||
projectsCache.forEach(project => {
|
||||
projectNameByUUID[project.uuid] = project.name;
|
||||
if (!project.is_active) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.uuid;
|
||||
option.textContent = project.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// keep default behavior without project selection data
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatestPricelistVersion() {
|
||||
try {
|
||||
const resp = await fetch('/api/pricelists/latest');
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||||
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Пересчитать цену
|
||||
Обновить цены
|
||||
</button>
|
||||
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||
Сохранить
|
||||
@@ -34,6 +34,14 @@
|
||||
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="updateServerCount()">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Прайслист</label>
|
||||
<select id="pricelist-select"
|
||||
class="w-56 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="updatePricelistSelection()">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
|
||||
</div>
|
||||
@@ -224,6 +232,7 @@ let cart = [];
|
||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||
let serverCount = 1; // Server count for the configuration
|
||||
let selectedPricelistId = null; // Selected pricelist (server ID)
|
||||
|
||||
// Autocomplete state
|
||||
let autocompleteInput = null;
|
||||
@@ -296,6 +305,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
serverCount = config.server_count || 1;
|
||||
document.getElementById('server-count').value = serverCount;
|
||||
document.getElementById('total-server-count').textContent = serverCount;
|
||||
selectedPricelistId = config.pricelist_id || null;
|
||||
|
||||
if (config.items && config.items.length > 0) {
|
||||
cart = config.items.map(item => ({
|
||||
@@ -322,6 +332,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadActivePricelists();
|
||||
await loadAllComponents();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
@@ -361,6 +372,44 @@ function updateServerCount() {
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
async function loadActivePricelists() {
|
||||
const select = document.getElementById('pricelist-select');
|
||||
if (!select) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/pricelists?active_only=true&per_page=200');
|
||||
const data = await resp.json();
|
||||
const pricelists = data.pricelists || [];
|
||||
|
||||
if (pricelists.length === 0) {
|
||||
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
|
||||
selectedPricelistId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
select.innerHTML = pricelists.map(pl => {
|
||||
return `<option value="${pl.id}">${pl.version}</option>`;
|
||||
}).join('');
|
||||
|
||||
const existing = selectedPricelistId && pricelists.some(pl => Number(pl.id) === Number(selectedPricelistId));
|
||||
if (existing) {
|
||||
select.value = String(selectedPricelistId);
|
||||
} else {
|
||||
selectedPricelistId = Number(pricelists[0].id);
|
||||
select.value = String(selectedPricelistId);
|
||||
}
|
||||
} catch (e) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
}
|
||||
}
|
||||
|
||||
function updatePricelistSelection() {
|
||||
const select = document.getElementById('pricelist-select');
|
||||
const next = parseInt(select.value);
|
||||
selectedPricelistId = Number.isFinite(next) && next > 0 ? next : null;
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function getCategoryFromLotName(lotName) {
|
||||
const parts = lotName.split('_');
|
||||
return parts[0] || '';
|
||||
@@ -1133,7 +1182,8 @@ async function saveConfig(showNotification = true) {
|
||||
items: cart,
|
||||
custom_price: customPrice,
|
||||
notes: '',
|
||||
server_count: serverCountValue
|
||||
server_count: serverCountValue,
|
||||
pricelist_id: selectedPricelistId
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1327,6 +1377,17 @@ async function refreshPrices() {
|
||||
if (config.price_updated_at) {
|
||||
updatePriceUpdateDate(config.price_updated_at);
|
||||
}
|
||||
if (config.pricelist_id) {
|
||||
selectedPricelistId = config.pricelist_id;
|
||||
const select = document.getElementById('pricelist-select');
|
||||
if (select) {
|
||||
const hasOption = Array.from(select.options).some(opt => Number(opt.value) === Number(selectedPricelistId));
|
||||
if (!hasOption) {
|
||||
await loadActivePricelists();
|
||||
}
|
||||
select.value = String(selectedPricelistId);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render UI
|
||||
renderTab();
|
||||
|
||||
@@ -147,12 +147,15 @@
|
||||
let settings = [];
|
||||
const hasManualPrice = item.manual_price && item.manual_price > 0;
|
||||
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
|
||||
const method = (item.price_method || '').toLowerCase();
|
||||
|
||||
// Method indicator
|
||||
if (hasManualPrice) {
|
||||
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
|
||||
} else if (item.price_method === 'average') {
|
||||
} else if (method === 'average') {
|
||||
settings.push('Сред');
|
||||
} else if (method === 'weighted_median') {
|
||||
settings.push('Взвеш. мед');
|
||||
} else {
|
||||
settings.push('Мед');
|
||||
}
|
||||
|
||||
476
web/templates/project_detail.html
Normal file
476
web/templates/project_detail.html
Normal file
@@ -0,0 +1,476 @@
|
||||
{{define "title"}}Проект - QuoteForge{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Назад к проектам">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold" id="project-title">Проект</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
+ Создать новую квоту
|
||||
</button>
|
||||
<button onclick="openImportModal()" class="py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
|
||||
Импорт квоты
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
<button id="status-active-btn" onclick="setConfigStatusMode('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||
Активные
|
||||
</button>
|
||||
<button id="status-archived-btn" onclick="setConfigStatusMode('archived')" class="px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
|
||||
Архив
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="configs-list">
|
||||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="create-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 class="block text-sm font-medium text-gray-700 mb-1">Название квоты</label>
|
||||
<input type="text" id="create-name" placeholder="Например: OPP-2026-001"
|
||||
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 space-x-3 mt-6">
|
||||
<button onclick="closeCreateModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="createConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rename-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 class="block text-sm font-medium text-gray-700 mb-1">Новое название</label>
|
||||
<input type="text" id="rename-input"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<input type="hidden" id="rename-uuid">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeRenameModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="renameConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="clone-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Копировать квоту</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название копии</label>
|
||||
<input type="text" id="clone-input"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<input type="hidden" id="clone-uuid">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeCloneModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="cloneConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Копировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="import-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-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Квота</label>
|
||||
<input id="import-config-input"
|
||||
list="import-config-options"
|
||||
placeholder="Начните вводить название квоты"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<datalist id="import-config-options"></datalist>
|
||||
<div class="text-xs text-gray-500">Квота будет перемещена в текущий проект.</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<button onclick="closeImportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="importConfigToProject()" class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Импортировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const projectUUID = '{{.ProjectUUID}}';
|
||||
let configStatusMode = 'active';
|
||||
let project = null;
|
||||
let allConfigs = [];
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function setConfigStatusMode(mode) {
|
||||
if (mode !== 'active' && mode !== 'archived') return;
|
||||
configStatusMode = mode;
|
||||
applyStatusModeUI();
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
function applyStatusModeUI() {
|
||||
const activeBtn = document.getElementById('status-active-btn');
|
||||
const archivedBtn = document.getElementById('status-archived-btn');
|
||||
const actionButtons = document.getElementById('action-buttons');
|
||||
|
||||
if (configStatusMode === 'archived') {
|
||||
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
|
||||
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white border-l border-gray-200';
|
||||
actionButtons.classList.add('hidden');
|
||||
} else {
|
||||
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white';
|
||||
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200';
|
||||
actionButtons.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function renderConfigs(configs) {
|
||||
const emptyText = configStatusMode === 'archived' ? 'Архив пуст' : 'Нет квот в проекте';
|
||||
if (configs.length === 0) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">' + emptyText + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let totalSum = 0;
|
||||
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||
html += '<thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
|
||||
configs.forEach(c => {
|
||||
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
||||
const total = c.total_price || 0;
|
||||
const serverCount = c.server_count || 1;
|
||||
const author = c.owner_username || (c.user && c.user.username) || '—';
|
||||
const unitPrice = serverCount > 0 ? (total / serverCount) : 0;
|
||||
totalSum += total;
|
||||
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
|
||||
} else {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||
}
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg></button>';
|
||||
} else {
|
||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
|
||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg></button>';
|
||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
|
||||
}
|
||||
html += '</td></tr>';
|
||||
});
|
||||
|
||||
html += '</tbody>';
|
||||
html += '<tfoot class="bg-gray-50 border-t">';
|
||||
html += '<tr>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="4">Итого по проекту</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
html += '<td class="px-4 py-3"></td>';
|
||||
html += '</tr>';
|
||||
html += '</tfoot>';
|
||||
html += '</table></div>';
|
||||
document.getElementById('configs-list').innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadProject() {
|
||||
const resp = await fetch('/api/projects/' + projectUUID);
|
||||
if (!resp.ok) {
|
||||
document.getElementById('configs-list').innerHTML = '<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Проект не найден</div>';
|
||||
return false;
|
||||
}
|
||||
project = await resp.json();
|
||||
document.getElementById('project-title').textContent = project.name;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadConfigs() {
|
||||
try {
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs?status=' + configStatusMode);
|
||||
if (!resp.ok) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
allConfigs = (data.configurations || []);
|
||||
renderConfigs(allConfigs);
|
||||
} catch (e) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
document.getElementById('create-name').value = '';
|
||||
document.getElementById('create-modal').classList.remove('hidden');
|
||||
document.getElementById('create-modal').classList.add('flex');
|
||||
document.getElementById('create-name').focus();
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
document.getElementById('create-modal').classList.add('hidden');
|
||||
document.getElementById('create-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function createConfig() {
|
||||
const name = document.getElementById('create-name').value.trim();
|
||||
if (!name) {
|
||||
alert('Введите название');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось создать квоту');
|
||||
return;
|
||||
}
|
||||
closeCreateModal();
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
async function deleteConfig(uuid) {
|
||||
if (!confirm('Переместить квоту в архив?')) return;
|
||||
await fetch('/api/configs/' + uuid, {method: 'DELETE'});
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
async function reactivateConfig(uuid) {
|
||||
const resp = await fetch('/api/configs/' + uuid + '/reactivate', {method: 'POST'});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось восстановить квоту');
|
||||
return;
|
||||
}
|
||||
const moved = await fetch('/api/configs/' + uuid + '/project', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({project_uuid: projectUUID})
|
||||
});
|
||||
if (!moved.ok) {
|
||||
alert('Квота восстановлена, но не удалось вернуть в проект');
|
||||
}
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
function openRenameModal(uuid, currentName) {
|
||||
document.getElementById('rename-uuid').value = uuid;
|
||||
document.getElementById('rename-input').value = currentName;
|
||||
document.getElementById('rename-modal').classList.remove('hidden');
|
||||
document.getElementById('rename-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeRenameModal() {
|
||||
document.getElementById('rename-modal').classList.add('hidden');
|
||||
document.getElementById('rename-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function renameConfig() {
|
||||
const uuid = document.getElementById('rename-uuid').value;
|
||||
const name = document.getElementById('rename-input').value.trim();
|
||||
if (!name) {
|
||||
alert('Введите название');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/configs/' + uuid + '/rename', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось переименовать');
|
||||
return;
|
||||
}
|
||||
closeRenameModal();
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
function openCloneModal(uuid, currentName) {
|
||||
document.getElementById('clone-uuid').value = uuid;
|
||||
document.getElementById('clone-input').value = currentName + ' (копия)';
|
||||
document.getElementById('clone-modal').classList.remove('hidden');
|
||||
document.getElementById('clone-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeCloneModal() {
|
||||
document.getElementById('clone-modal').classList.add('hidden');
|
||||
document.getElementById('clone-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function cloneConfig() {
|
||||
const uuid = document.getElementById('clone-uuid').value;
|
||||
const name = document.getElementById('clone-input').value.trim();
|
||||
if (!name) {
|
||||
alert('Введите название');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs/' + uuid + '/clone', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось скопировать');
|
||||
return;
|
||||
}
|
||||
closeCloneModal();
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
function openImportModal() {
|
||||
const activeOther = allConfigs.length ? null : null; // no-op placeholder
|
||||
void activeOther;
|
||||
document.getElementById('import-config-input').value = '';
|
||||
document.getElementById('import-config-options').innerHTML = '';
|
||||
loadImportOptions();
|
||||
document.getElementById('import-modal').classList.remove('hidden');
|
||||
document.getElementById('import-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeImportModal() {
|
||||
document.getElementById('import-modal').classList.add('hidden');
|
||||
document.getElementById('import-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function loadImportOptions() {
|
||||
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const options = document.getElementById('import-config-options');
|
||||
options.innerHTML = '';
|
||||
(data.configurations || [])
|
||||
.filter(c => c.project_uuid !== projectUUID)
|
||||
.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.name;
|
||||
options.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
async function importConfigToProject() {
|
||||
const query = document.getElementById('import-config-input').value.trim();
|
||||
if (!query) {
|
||||
alert('Выберите квоту');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось загрузить список квот');
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const sourceConfigs = (data.configurations || []).filter(c => c.project_uuid !== projectUUID);
|
||||
|
||||
let targets = [];
|
||||
if (query.includes('*')) {
|
||||
targets = sourceConfigs.filter(c => wildcardMatch(c.name || '', query));
|
||||
} else {
|
||||
const found = sourceConfigs.find(c => (c.name || '').toLowerCase() === query.toLowerCase());
|
||||
if (found) {
|
||||
targets = [found];
|
||||
}
|
||||
}
|
||||
|
||||
if (!targets.length) {
|
||||
alert('Подходящие квоты не найдены');
|
||||
return;
|
||||
}
|
||||
|
||||
let moved = 0;
|
||||
let failed = 0;
|
||||
for (const cfg of targets) {
|
||||
const move = await fetch('/api/configs/' + cfg.uuid + '/project', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({project_uuid: projectUUID})
|
||||
});
|
||||
if (move.ok) {
|
||||
moved++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!moved) {
|
||||
alert('Не удалось импортировать квоты');
|
||||
return;
|
||||
}
|
||||
|
||||
closeImportModal();
|
||||
await loadConfigs();
|
||||
if (targets.length > 1 || failed > 0) {
|
||||
alert('Импорт завершен: перенесено ' + moved + ', ошибок ' + failed);
|
||||
}
|
||||
}
|
||||
|
||||
function wildcardMatch(value, pattern) {
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*');
|
||||
const regex = new RegExp('^' + escaped + '$', 'i');
|
||||
return regex.test(value);
|
||||
}
|
||||
|
||||
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
||||
document.getElementById('rename-modal').addEventListener('click', function(e) { if (e.target === this) closeRenameModal(); });
|
||||
document.getElementById('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); });
|
||||
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeCreateModal();
|
||||
closeRenameModal();
|
||||
closeCloneModal();
|
||||
closeImportModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
applyStatusModeUI();
|
||||
const ok = await loadProject();
|
||||
if (!ok) return;
|
||||
await loadConfigs();
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
231
web/templates/projects.html
Normal file
231
web/templates/projects.html
Normal file
@@ -0,0 +1,231 @@
|
||||
{{define "title"}}Мои проекты - QuoteForge{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold">Мои проекты</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/configs" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
|
||||
Все конфигурации
|
||||
</a>
|
||||
<button onclick="createProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
+ Новый проект
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
<button id="status-active-btn" onclick="setStatus('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">Активные</button>
|
||||
<button id="status-archived-btn" onclick="setStatus('archived')" class="px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">Архив</button>
|
||||
</div>
|
||||
|
||||
<div class="max-w-md">
|
||||
<input id="projects-search" type="text" placeholder="Поиск проекта по названию"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div id="projects-table" class="bg-white rounded-lg shadow p-4 text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let status = 'active';
|
||||
let projectsSearch = '';
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatMoney(v) {
|
||||
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
}
|
||||
|
||||
function setStatus(value) {
|
||||
status = value;
|
||||
document.getElementById('status-active-btn').className = value === 'active'
|
||||
? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white'
|
||||
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
|
||||
document.getElementById('status-archived-btn').className = value === 'archived'
|
||||
? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white border-l border-gray-200'
|
||||
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200';
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const root = document.getElementById('projects-table');
|
||||
root.innerHTML = '<div class="text-gray-500">Загрузка...</div>';
|
||||
|
||||
let rows = [];
|
||||
try {
|
||||
const resp = await fetch('/api/projects?status=' + status + '&search=' + encodeURIComponent(projectsSearch));
|
||||
if (!resp.ok) {
|
||||
throw new Error('HTTP ' + resp.status);
|
||||
}
|
||||
const data = await resp.json();
|
||||
rows = data.projects || [];
|
||||
} catch (e) {
|
||||
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
root.innerHTML = '<div class="text-gray-500">Проектов нет</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
||||
html += '<thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название проекта</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Кол-во квот</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
|
||||
rows.forEach(p => {
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
||||
|
||||
if (p.is_active) {
|
||||
html += '<button onclick="copyProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-green-700 hover:text-green-900" title="Копировать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>';
|
||||
html += '</button>';
|
||||
|
||||
html += '<button onclick="renameProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>';
|
||||
html += '</button>';
|
||||
|
||||
html += '<button onclick="archiveProject(\'' + p.uuid + '\')" class="text-red-700 hover:text-red-900" title="Удалить (в архив)">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>';
|
||||
html += '</button>';
|
||||
|
||||
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" title="Добавить квоту">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>';
|
||||
html += '</button>';
|
||||
} else {
|
||||
html += '<button onclick="reactivateProject(\'' + p.uuid + '\')" class="text-emerald-700 hover:text-emerald-900" title="Восстановить">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>';
|
||||
html += '</button>';
|
||||
}
|
||||
html += '</div></td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
root.innerHTML = html;
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
const name = prompt('Название проекта');
|
||||
if (!name || !name.trim()) return;
|
||||
const resp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name.trim()})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось создать проект');
|
||||
return;
|
||||
}
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
async function renameProject(projectUUID, currentName) {
|
||||
const name = prompt('Новое название проекта', currentName);
|
||||
if (!name || !name.trim() || name.trim() === currentName) return;
|
||||
const resp = await fetch('/api/projects/' + projectUUID, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name.trim()})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось переименовать проект');
|
||||
return;
|
||||
}
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
async function archiveProject(projectUUID) {
|
||||
if (!confirm('Переместить проект в архив?')) return;
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/archive', {method: 'POST'});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось архивировать проект');
|
||||
return;
|
||||
}
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
async function reactivateProject(projectUUID) {
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/reactivate', {method: 'POST'});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось восстановить проект');
|
||||
return;
|
||||
}
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
async function addConfigToProject(projectUUID) {
|
||||
const name = prompt('Название новой квоты');
|
||||
if (!name || !name.trim()) return;
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name.trim(), items: [], notes: '', server_count: 1})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось создать квоту');
|
||||
return;
|
||||
}
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
async function copyProject(projectUUID, projectName) {
|
||||
const newName = prompt('Название копии проекта', projectName + ' (копия)');
|
||||
if (!newName || !newName.trim()) return;
|
||||
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: newName.trim()})
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
alert('Не удалось создать копию проекта');
|
||||
return;
|
||||
}
|
||||
const newProject = await createResp.json();
|
||||
|
||||
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
|
||||
if (!listResp.ok) {
|
||||
alert('Проект скопирован без квот (не удалось загрузить исходные квоты)');
|
||||
loadProjects();
|
||||
return;
|
||||
}
|
||||
const listData = await listResp.json();
|
||||
const configs = listData.configurations || [];
|
||||
|
||||
for (const cfg of configs) {
|
||||
await fetch('/api/projects/' + newProject.uuid + '/configs/' + cfg.uuid + '/clone', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: cfg.name})
|
||||
});
|
||||
}
|
||||
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
loadProjects();
|
||||
|
||||
document.getElementById('projects-search').addEventListener('input', function(e) {
|
||||
projectsSearch = (e.target.value || '').trim();
|
||||
loadProjects();
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
Reference in New Issue
Block a user