package storage import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "dana/internal/domain" ) var ErrNotFound = errors.New("not found") // wrapNotFound переводит «пустой результат» pgx в ErrNotFound, прочие ошибки // возвращает как есть. func wrapNotFound(err error) error { if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } return err } const serviceColumns = `s.id, s.parent_id, s.slug, s.path, s.name, s.short_name, s.lead, s.icon, COALESCE(m.path, ''), s.seo_title, s.seo_description, s.seo_h1` func scanService(row pgx.Row, s *domain.Service) error { return row.Scan(&s.ID, &s.ParentID, &s.Slug, &s.Path, &s.Name, &s.ShortName, &s.Lead, &s.Icon, &s.Image, &s.SEO.Title, &s.SEO.Description, &s.SEO.H1) } // ServiceTree возвращает опубликованные услуги деревом. func (s *Storage) ServiceTree(ctx context.Context) ([]*domain.Service, error) { rows, err := s.pool.Query(ctx, ` SELECT `+serviceColumns+` FROM services s LEFT JOIN media m ON m.id = s.image_id WHERE s.published AND s.deleted_at IS NULL ORDER BY s.sort, s.name`) if err != nil { return nil, err } defer rows.Close() byID := map[int64]*domain.Service{} var all []*domain.Service for rows.Next() { svc := &domain.Service{} if err := scanService(rows, svc); err != nil { return nil, err } byID[svc.ID] = svc all = append(all, svc) } if err := rows.Err(); err != nil { return nil, err } var roots []*domain.Service for _, svc := range all { if svc.ParentID == nil { roots = append(roots, svc) continue } if parent, ok := byID[*svc.ParentID]; ok { parent.Children = append(parent.Children, svc) } } return roots, nil } // ServiceByPath возвращает карточку услуги с ценами и врачами. func (s *Storage) ServiceByPath(ctx context.Context, path string) (*domain.Service, error) { svc := &domain.Service{} row := s.pool.QueryRow(ctx, ` SELECT `+serviceColumns+`, s.blocks FROM services s LEFT JOIN media m ON m.id = s.image_id WHERE s.path = $1 AND s.published AND s.deleted_at IS NULL`, path) err := row.Scan(&svc.ID, &svc.ParentID, &svc.Slug, &svc.Path, &svc.Name, &svc.ShortName, &svc.Lead, &svc.Icon, &svc.Image, &svc.SEO.Title, &svc.SEO.Description, &svc.SEO.H1, &svc.Blocks) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if svc.Prices, svc.PriceFrom, err = s.pricesForService(ctx, svc.ID); err != nil { return nil, fmt.Errorf("prices: %w", err) } if svc.Doctors, err = s.DoctorsByService(ctx, svc.ID); err != nil { return nil, fmt.Errorf("doctors: %w", err) } children, err := s.childServices(ctx, svc.ID) if err != nil { return nil, err } svc.Children = children return svc, nil } func (s *Storage) childServices(ctx context.Context, parentID int64) ([]*domain.Service, error) { rows, err := s.pool.Query(ctx, ` SELECT `+serviceColumns+` FROM services s LEFT JOIN media m ON m.id = s.image_id WHERE s.parent_id = $1 AND s.published AND s.deleted_at IS NULL ORDER BY s.sort, s.name`, parentID) if err != nil { return nil, err } defer rows.Close() var out []*domain.Service for rows.Next() { svc := &domain.Service{} if err := scanService(rows, svc); err != nil { return nil, err } out = append(out, svc) } return out, rows.Err() } // pricesForService отдаёт позиции прайса услуги и минимальную цену для «от N ₽». func (s *Storage) pricesForService(ctx context.Context, serviceID int64) ([]domain.PriceItem, *float64, error) { rows, err := s.pool.Query(ctx, ` SELECT id, name, price, unit, price_from, exclude_from_min FROM price_items WHERE service_id = $1 AND published ORDER BY sort, name`, serviceID) if err != nil { return nil, nil, err } defer rows.Close() var items []domain.PriceItem var min *float64 for rows.Next() { var it domain.PriceItem var excluded bool if err := rows.Scan(&it.ID, &it.Name, &it.Price, &it.Unit, &it.From, &excluded); err != nil { return nil, nil, err } items = append(items, it) if !excluded && (min == nil || it.Price < *min) { v := it.Price min = &v } } return items, min, rows.Err() }