dana-medical-site/internal/storage/doctors.go
2026-10-14 13:33:13 +00:00

323 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.medflex_user, 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')`
const medflexBase = "https://booking.medflex.ru/?user="
func scanDoctor(row pgx.Row) (domain.Doctor, error) {
var d domain.Doctor
var startYear *int
var medflexUser string
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, &medflexUser, &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 == "medflex" && medflexUser != "" {
// При переносе со старого сайта в поле попала ссылка целиком;
// через админку можно ввести и только идентификатор.
if strings.HasPrefix(medflexUser, "http://") || strings.HasPrefix(medflexUser, "https://") {
d.Booking.URL = medflexUser
} else {
d.Booking.URL = medflexBase + medflexUser
}
}
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()
}