diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 4525d56..c3f3b45 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1130,6 +1130,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect "uuid": p.UUID, "owner_username": p.OwnerUsername, "name": p.Name, + "tracker_url": p.TrackerURL, "is_active": p.IsActive, "is_system": p.IsSystem, "created_at": p.CreatedAt, diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index d79aed3..57b3789 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -99,6 +99,7 @@ func ProjectToLocal(project *models.Project) *LocalProject { UUID: project.UUID, OwnerUsername: project.OwnerUsername, Name: project.Name, + TrackerURL: project.TrackerURL, IsActive: project.IsActive, IsSystem: project.IsSystem, CreatedAt: project.CreatedAt, @@ -117,6 +118,7 @@ func LocalToProject(local *LocalProject) *models.Project { UUID: local.UUID, OwnerUsername: local.OwnerUsername, Name: local.Name, + TrackerURL: local.TrackerURL, IsActive: local.IsActive, IsSystem: local.IsSystem, CreatedAt: local.CreatedAt, diff --git a/internal/localdb/models.go b/internal/localdb/models.go index 703dc30..32c1aa5 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -94,6 +94,7 @@ type LocalProject struct { ServerID *uint `json:"server_id,omitempty"` OwnerUsername string `gorm:"not null;index" json:"owner_username"` Name string `gorm:"not null" json:"name"` + TrackerURL string `json:"tracker_url"` IsActive bool `gorm:"default:true;index" json:"is_active"` IsSystem bool `gorm:"default:false;index" json:"is_system"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/models/project.go b/internal/models/project.go index 0b08646..9c3ea73 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -7,6 +7,7 @@ type Project struct { UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"` Name string `gorm:"size:200;not null" json:"name"` + TrackerURL string `gorm:"size:500" json:"tracker_url"` IsActive bool `gorm:"default:true;index" json:"is_active"` IsSystem bool `gorm:"default:false;index" json:"is_system"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` diff --git a/internal/repository/project.go b/internal/repository/project.go index eb49918..c03e54e 100644 --- a/internal/repository/project.go +++ b/internal/repository/project.go @@ -3,6 +3,7 @@ package repository import ( "git.mchus.pro/mchus/quoteforge/internal/models" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type ProjectRepository struct { @@ -21,6 +22,30 @@ func (r *ProjectRepository) Update(project *models.Project) error { return r.db.Save(project).Error } +func (r *ProjectRepository) UpsertByUUID(project *models.Project) error { + if err := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "uuid"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "owner_username", + "name", + "tracker_url", + "is_active", + "is_system", + "updated_at", + }), + }).Create(project).Error; err != nil { + return err + } + + // Ensure caller always gets canonical server ID. + var persisted models.Project + if err := r.db.Where("uuid = ?", project.UUID).First(&persisted).Error; err != nil { + return err + } + project.ID = persisted.ID + return nil +} + func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) { var project models.Project if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil { diff --git a/internal/services/project.go b/internal/services/project.go index 7f87a79..1e34be3 100644 --- a/internal/services/project.go +++ b/internal/services/project.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "strings" "time" @@ -28,11 +29,13 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService { } type CreateProjectRequest struct { - Name string `json:"name"` + Name string `json:"name"` + TrackerURL string `json:"tracker_url"` } type UpdateProjectRequest struct { - Name string `json:"name"` + Name string `json:"name"` + TrackerURL *string `json:"tracker_url,omitempty"` } type ProjectConfigurationsResult struct { @@ -52,6 +55,7 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) UUID: uuid.NewString(), OwnerUsername: ownerUsername, Name: name, + TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL), IsActive: true, IsSystem: false, CreatedAt: now, @@ -82,6 +86,11 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr } localProject.Name = name + if req.TrackerURL != nil { + localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL) + } else if strings.TrimSpace(localProject.TrackerURL) == "" { + localProject.TrackerURL = normalizeProjectTrackerURL(name, "") + } localProject.UpdatedAt = time.Now() localProject.SyncStatus = "pending" if err := s.localDB.SaveProject(localProject); err != nil { @@ -260,6 +269,20 @@ func (s *ProjectService) ResolveProjectUUID(ownerUsername string, projectUUID *s return &resolved, nil } +func normalizeProjectTrackerURL(projectCode, trackerURL string) string { + trimmedURL := strings.TrimSpace(trackerURL) + if trimmedURL != "" { + return trimmedURL + } + + trimmedCode := strings.TrimSpace(projectCode) + if trimmedCode == "" { + return "" + } + + return "https://tracker.yandex.ru/" + url.PathEscape(trimmedCode) +} + func (s *ProjectService) enqueueProjectPendingChange(project *localdb.LocalProject, operation string) error { return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation) } diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 4c03f2b..32230b6 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -201,6 +201,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) { existing.OwnerUsername = project.OwnerUsername existing.Name = project.Name + existing.TrackerURL = project.TrackerURL existing.IsActive = project.IsActive existing.IsSystem = project.IsSystem existing.CreatedAt = project.CreatedAt @@ -757,20 +758,8 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error { project := payload.Snapshot project.UUID = payload.ProjectUUID - serverProject, err := projectRepo.GetByUUID(project.UUID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - if createErr := projectRepo.Create(&project); createErr != nil { - return fmt.Errorf("create project on server: %w", createErr) - } - } else { - return fmt.Errorf("get project on server: %w", err) - } - } else { - project.ID = serverProject.ID - if updateErr := projectRepo.Update(&project); updateErr != nil { - return fmt.Errorf("update project on server: %w", updateErr) - } + if err := projectRepo.UpsertByUUID(&project); err != nil { + return fmt.Errorf("upsert project on server: %w", err) } localProject, localErr := s.localDB.GetProjectByUUID(project.UUID) @@ -1032,7 +1021,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi if modelProject.OwnerUsername == "" { modelProject.OwnerUsername = cfg.OwnerUsername } - if createErr := projectRepo.Create(modelProject); createErr != nil { + if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil { return createErr } if modelProject.ID > 0 { diff --git a/internal/services/sync/service_projects_push_test.go b/internal/services/sync/service_projects_push_test.go index bad7e91..bafd093 100644 --- a/internal/services/sync/service_projects_push_test.go +++ b/internal/services/sync/service_projects_push_test.go @@ -65,6 +65,54 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) { } } +func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T) { + local := newLocalDBForSyncTest(t) + serverDB := newServerDBForSyncTest(t) + + localSync := syncsvc.NewService(nil, local) + projectService := services.NewProjectService(local) + configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false }) + pushService := syncsvc.NewServiceWithDB(serverDB, local) + + project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project v1"}) + if err != nil { + t.Fatalf("create project: %v", err) + } + if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil { + t.Fatalf("update project: %v", err) + } + + cfg, err := configService.Create("tester", &services.CreateConfigRequest{ + Name: "Cfg linked", + Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}}, + ServerCount: 1, + ProjectUUID: &project.UUID, + }) + if err != nil { + t.Fatalf("create config: %v", err) + } + + if _, err := pushService.PushPendingChanges(); err != nil { + t.Fatalf("push pending changes: %v", err) + } + + var serverProject models.Project + if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil { + t.Fatalf("project not pushed to server: %v", err) + } + if serverProject.Name != "Project v2" { + t.Fatalf("expected latest project name, got %q", serverProject.Name) + } + + var serverCfg models.Configuration + if err := serverDB.Where("uuid = ?", cfg.UUID).First(&serverCfg).Error; err != nil { + t.Fatalf("configuration not pushed to server: %v", err) + } + if serverCfg.ProjectUUID == nil || *serverCfg.ProjectUUID != project.UUID { + t.Fatalf("expected project_uuid=%s on pushed config, got %v", project.UUID, serverCfg.ProjectUUID) + } +} + func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(t *testing.T) { local := newLocalDBForSyncTest(t) serverDB := newServerDBForSyncTest(t) @@ -277,6 +325,7 @@ CREATE TABLE qt_projects ( uuid TEXT NOT NULL UNIQUE, owner_username TEXT NOT NULL, name TEXT NOT NULL, + tracker_url TEXT NULL, is_active INTEGER NOT NULL DEFAULT 1, is_system INTEGER NOT NULL DEFAULT 0, created_at DATETIME, diff --git a/migrations/012_add_project_tracker_url.sql b/migrations/012_add_project_tracker_url.sql new file mode 100644 index 0000000..0e96fb1 --- /dev/null +++ b/migrations/012_add_project_tracker_url.sql @@ -0,0 +1,7 @@ +ALTER TABLE qt_projects + ADD COLUMN tracker_url VARCHAR(500) NULL AFTER name; + +UPDATE qt_projects +SET tracker_url = CONCAT('https://tracker.yandex.ru/', TRIM(name)) +WHERE (tracker_url IS NULL OR tracker_url = '') + AND TRIM(COALESCE(name, '')) <> ''; diff --git a/web/templates/project_detail.html b/web/templates/project_detail.html index 6e36a65..744939a 100644 --- a/web/templates/project_detail.html +++ b/web/templates/project_detail.html @@ -125,6 +125,12 @@ function escapeHtml(text) { return div.innerHTML; } +function resolveProjectTrackerURL(projectData) { + if (!projectData) return ''; + const explicitURL = (projectData.tracker_url || '').trim(); + return explicitURL; +} + function setConfigStatusMode(mode) { if (mode !== 'active' && mode !== 'archived') return; configStatusMode = mode; @@ -224,8 +230,18 @@ async function loadProject() { project = await resp.json(); document.getElementById('project-title').textContent = project.name; const trackerLink = document.getElementById('tracker-link'); - if (trackerLink && project && project.name) { - trackerLink.href = 'https://tracker.yandex.ru/' + encodeURIComponent(project.name); + if (trackerLink) { + if (project && project.is_system) { + trackerLink.classList.add('hidden'); + return true; + } + const trackerURL = resolveProjectTrackerURL(project); + if (trackerURL) { + trackerLink.href = trackerURL; + trackerLink.classList.remove('hidden'); + } else { + trackerLink.classList.add('hidden'); + } } return true; } diff --git a/web/templates/projects.html b/web/templates/projects.html index 7d4a6ef..0742033 100644 --- a/web/templates/projects.html +++ b/web/templates/projects.html @@ -8,7 +8,7 @@ Все конфигурации - @@ -27,6 +27,28 @@
Загрузка...
+ + {{end}}