package storage import ( "context" "errors" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "dana/internal/domain" ) // Число отзывов считается по опубликованным отзывам. Рейтинг из админки имеет // приоритет, а при пустом значении используется средняя оценка отзывов. const doctorColumns = `d.id, d.slug, d.last_name, d.first_name, d.middle_name, d.position, d.category, d.degree, d.practice_start_year, COALESCE(m.path, ''), d.quote, d.languages, d.accepts_kids_from, d.booking_provider, d.absent, d.seo_title, d.seo_description, d.seo_h1, COALESCE(d.rating_override::float8, (SELECT round(avg(r.rating)::numeric, 1)::float8 FROM reviews r WHERE r.doctor_id = d.id AND r.status = 'published' AND r.rating IS NOT NULL)), (SELECT count(*) FROM reviews r WHERE r.doctor_id = d.id AND r.status = 'published')` func scanDoctor(row pgx.Row) (domain.Doctor, error) { var d domain.Doctor var startYear *int err := row.Scan(&d.ID, &d.Slug, &d.LastName, &d.FirstName, &d.MiddleName, &d.Position, &d.Category, &d.Degree, &startYear, &d.Photo, &d.Quote, &d.Languages, &d.KidsFrom, &d.Booking.Provider, &d.Absent, &d.SEO.Title, &d.SEO.Description, &d.SEO.H1, &d.Rating, &d.ReviewCount) if err != nil { return d, err } d.FullName = strings.TrimSpace(strings.Join([]string{d.LastName, d.FirstName, d.MiddleName}, " ")) if startYear != nil && *startYear > 0 { // Стаж считается, а не хранится текстом — иначе он устаревает. years := time.Now().Year() - *startYear if years >= 0 { d.Experience = &years } } if d.Booking.Provider != "native" { d.Booking.Provider = "none" } return d, nil } func (s *Storage) collectDoctors(ctx context.Context, sql string, args ...any) ([]domain.Doctor, error) { rows, err := s.pool.Query(ctx, sql, args...) if err != nil { return nil, err } defer rows.Close() var out []domain.Doctor for rows.Next() { d, err := scanDoctor(rows) if err != nil { return nil, err } out = append(out, d) } return out, rows.Err() } // DoctorFilter — необязательные фильтры списка врачей. Пустое поле не фильтрует. type DoctorFilter struct { Specialty string // slug специальности Service string // path услуги Branch string // slug филиала Query string // поиск по ФИО и должности } func (s *Storage) Doctors(ctx context.Context, f DoctorFilter) ([]domain.Doctor, error) { sql := ` SELECT ` + doctorColumns + ` FROM doctors d LEFT JOIN media m ON m.id = d.photo_id WHERE d.published AND d.deleted_at IS NULL` var args []any arg := func(v any) string { args = append(args, v) return "$" + strconv.Itoa(len(args)) } if f.Specialty != "" { sql += ` AND EXISTS ( SELECT 1 FROM doctor_specialties ds JOIN specialties sp ON sp.id = ds.specialty_id WHERE ds.doctor_id = d.id AND sp.slug = ` + arg(f.Specialty) + `)` } if f.Service != "" { sql += ` AND EXISTS ( SELECT 1 FROM doctor_services dsv JOIN services sv ON sv.id = dsv.service_id WHERE dsv.doctor_id = d.id AND sv.path = ` + arg(f.Service) + `)` } if f.Branch != "" { sql += ` AND EXISTS ( SELECT 1 FROM doctor_branches db JOIN branches b ON b.id = db.branch_id WHERE db.doctor_id = d.id AND b.slug = ` + arg(f.Branch) + `)` } if f.Query != "" { p := arg(likePattern(f.Query)) sql += ` AND (concat_ws(' ', d.last_name, d.first_name, d.middle_name) ILIKE ` + p + ` ESCAPE '\' OR d.position ILIKE ` + p + ` ESCAPE '\')` } sql += ` ORDER BY d.sort, d.last_name, d.id` doctors, err := s.collectDoctors(ctx, sql, args...) if err != nil { return nil, err } return s.attachSpecialties(ctx, doctors) } func (s *Storage) DoctorsByService(ctx context.Context, serviceID int64) ([]domain.Doctor, error) { doctors, err := s.collectDoctors(ctx, ` SELECT `+doctorColumns+` FROM doctors d JOIN doctor_services ds ON ds.doctor_id = d.id LEFT JOIN media m ON m.id = d.photo_id WHERE ds.service_id = $1 AND d.published AND d.deleted_at IS NULL ORDER BY d.sort, d.last_name, d.id`, serviceID) if err != nil { return nil, err } // Cards and JSON-LD must use the same primary-specialty URL as the directory. return s.attachSpecialties(ctx, doctors) } func (s *Storage) DoctorBySlug(ctx context.Context, slug string) (*domain.Doctor, error) { row := s.pool.QueryRow(ctx, ` SELECT `+doctorColumns+` FROM doctors d LEFT JOIN media m ON m.id = d.photo_id WHERE d.slug = $1 AND d.published AND d.deleted_at IS NULL`, slug) d, err := scanDoctor(row) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if err := s.pool.QueryRow(ctx, `SELECT bio FROM doctors WHERE id = $1`, d.ID).Scan(&d.Bio); err != nil { return nil, err } entries, err := s.doctorBio(ctx, d.ID) if err != nil { return nil, err } d.BioEntries = entries services, err := s.doctorServices(ctx, d.ID) if err != nil { return nil, err } d.Services = services branches, err := s.doctorBranches(ctx, d.ID) if err != nil { return nil, err } d.Branches = branches withSpec, err := s.attachSpecialties(ctx, []domain.Doctor{d}) if err != nil { return nil, err } return &withSpec[0], nil } func (s *Storage) doctorBio(ctx context.Context, doctorID int64) ([]domain.BioEntry, error) { rows, err := s.pool.Query(ctx, ` SELECT kind, year_from, year_to, title, description FROM doctor_bio_entries WHERE doctor_id = $1 ORDER BY sort, year_from DESC NULLS LAST, id`, doctorID) if err != nil { return nil, err } defer rows.Close() var out []domain.BioEntry for rows.Next() { var e domain.BioEntry if err := rows.Scan(&e.Kind, &e.YearFrom, &e.YearTo, &e.Title, &e.Description); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } func (s *Storage) doctorServices(ctx context.Context, doctorID int64) ([]domain.DoctorService, error) { rows, err := s.pool.Query(ctx, ` SELECT sv.name, sv.path, sv.lead FROM doctor_services ds JOIN services sv ON sv.id = ds.service_id WHERE ds.doctor_id = $1 AND sv.published AND sv.deleted_at IS NULL ORDER BY sv.sort, sv.name, sv.id`, doctorID) if err != nil { return nil, err } defer rows.Close() var out []domain.DoctorService for rows.Next() { var ds domain.DoctorService if err := rows.Scan(&ds.Name, &ds.Path, &ds.Lead); err != nil { return nil, err } out = append(out, ds) } return out, rows.Err() } func (s *Storage) doctorBranches(ctx context.Context, doctorID int64) ([]domain.DoctorBranch, error) { rows, err := s.pool.Query(ctx, ` SELECT b.slug, b.name, b.address FROM doctor_branches db JOIN branches b ON b.id = db.branch_id WHERE db.doctor_id = $1 AND b.published ORDER BY b.sort, b.name`, doctorID) if err != nil { return nil, err } defer rows.Close() var out []domain.DoctorBranch for rows.Next() { var b domain.DoctorBranch if err := rows.Scan(&b.Slug, &b.Name, &b.Address); err != nil { return nil, err } out = append(out, b) } return out, rows.Err() } // attachSpecialties догружает специальности одним запросом, без N+1. func (s *Storage) attachSpecialties(ctx context.Context, doctors []domain.Doctor) ([]domain.Doctor, error) { if len(doctors) == 0 { return doctors, nil } ids := make([]int64, len(doctors)) index := make(map[int64]int, len(doctors)) for i, d := range doctors { ids[i] = d.ID index[d.ID] = i } rows, err := s.pool.Query(ctx, ` SELECT ds.doctor_id, sp.id, sp.slug, sp.name, sp.name_plural FROM doctor_specialties ds JOIN specialties sp ON sp.id = ds.specialty_id WHERE ds.doctor_id = ANY($1) AND sp.published ORDER BY ds.doctor_id, ds.is_primary DESC, sp.sort, sp.id`, ids) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var doctorID int64 var sp domain.Specialty if err := rows.Scan(&doctorID, &sp.ID, &sp.Slug, &sp.Name, &sp.NamePlural); err != nil { return nil, err } if i, ok := index[doctorID]; ok { doctors[i].Specialties = append(doctors[i].Specialties, sp) } } return doctors, rows.Err() } func (s *Storage) Specialties(ctx context.Context) ([]domain.Specialty, error) { rows, err := s.pool.Query(ctx, ` SELECT sp.id, sp.slug, sp.name, sp.name_plural, COUNT(d.id) FROM specialties sp LEFT JOIN doctor_specialties ds ON ds.specialty_id = sp.id LEFT JOIN doctors d ON d.id = ds.doctor_id AND d.published AND d.deleted_at IS NULL WHERE sp.published GROUP BY sp.id ORDER BY sp.sort, sp.name_plural, sp.id`) if err != nil { return nil, err } defer rows.Close() var out []domain.Specialty for rows.Next() { var sp domain.Specialty if err := rows.Scan(&sp.ID, &sp.Slug, &sp.Name, &sp.NamePlural, &sp.Count); err != nil { return nil, err } out = append(out, sp) } return out, rows.Err() }