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 @@
Все конфигурации
-