package services import ( "encoding/json" "errors" "fmt" "net/url" "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") ErrProjectCodeExists = errors.New("project code and variant already exist") ErrCannotDeleteMainVariant = errors.New("cannot delete main variant") ) type ProjectService struct { localDB *localdb.LocalDB } func NewProjectService(localDB *localdb.LocalDB) *ProjectService { return &ProjectService{localDB: localDB} } type CreateProjectRequest struct { Code string `json:"code"` Variant string `json:"variant,omitempty"` Name *string `json:"name,omitempty"` TrackerURL string `json:"tracker_url"` } type UpdateProjectRequest struct { Code *string `json:"code,omitempty"` Variant *string `json:"variant,omitempty"` Name *string `json:"name,omitempty"` TrackerURL *string `json:"tracker_url,omitempty"` } 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) { var namePtr *string if req.Name != nil { name := strings.TrimSpace(*req.Name) if name != "" { namePtr = &name } } code := strings.TrimSpace(req.Code) if code == "" { return nil, fmt.Errorf("project code is required") } variant := strings.TrimSpace(req.Variant) if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil { return nil, err } now := time.Now() localProject := &localdb.LocalProject{ UUID: uuid.NewString(), OwnerUsername: ownerUsername, Code: code, Variant: variant, Name: namePtr, TrackerURL: normalizeProjectTrackerURL(code, req.TrackerURL), 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 req.Code != nil { code := strings.TrimSpace(*req.Code) if code == "" { return nil, fmt.Errorf("project code is required") } localProject.Code = code } if req.Variant != nil { localProject.Variant = strings.TrimSpace(*req.Variant) } if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil { return nil, err } if req.Name != nil { name := strings.TrimSpace(*req.Name) if name == "" { localProject.Name = nil } else { localProject.Name = &name } } if req.TrackerURL != nil { localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, *req.TrackerURL) } else if strings.TrimSpace(localProject.TrackerURL) == "" { localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "") } 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) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error { normalizedCode := normalizeProjectCode(code) normalizedVariant := normalizeProjectVariant(variant) if normalizedCode == "" { return fmt.Errorf("project code is required") } projects, err := s.localDB.GetAllProjects(true) if err != nil { return err } for i := range projects { project := projects[i] if excludeUUID != "" && project.UUID == excludeUUID { continue } if normalizeProjectCode(project.Code) == normalizedCode && normalizeProjectVariant(project.Variant) == normalizedVariant { return ErrProjectCodeExists } } return nil } func normalizeProjectCode(code string) string { return strings.ToLower(strings.TrimSpace(code)) } func normalizeProjectVariant(variant string) string { return strings.ToLower(strings.TrimSpace(variant)) } 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) DeleteVariant(projectUUID, ownerUsername string) error { localProject, err := s.localDB.GetProjectByUUID(projectUUID) if err != nil { return ErrProjectNotFound } if strings.TrimSpace(localProject.Variant) == "" { return ErrCannotDeleteMainVariant } return s.setProjectActive(projectUUID, ownerUsername, false) } 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.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 } query := s.localDB.DB(). Preload("CurrentVersion"). Where("project_uuid = ?", projectUUID). Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC") switch status { case "active", "": query = query.Where("is_active = ?", true) case "archived": query = query.Where("is_active = ?", false) case "all": default: query = query.Where("is_active = ?", true) } var localConfigs []localdb.LocalConfiguration if err := query.Find(&localConfigs).Error; err != nil { return nil, err } configs := make([]models.Configuration, 0, len(localConfigs)) total := 0.0 for i := range localConfigs { localCfg := localConfigs[i] 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 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) } 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 }