fix(booking): remove Medflex and keep appointments on site

This commit is contained in:
Dana Medical Developer 2026-10-22 07:40:00 +00:00
parent 30cdeb8dbc
commit 6e30d4cb5f
11 changed files with 120 additions and 45 deletions

View File

@ -9,7 +9,7 @@ import (
"dana/internal/storage" "dana/internal/storage"
) )
var bookingProviders = map[string]bool{"none": true, "native": true, "medflex": true} var bookingProviders = map[string]bool{"none": true, "native": true}
var bioKinds = map[string]bool{ var bioKinds = map[string]bool{
"education": true, "experience": true, "training": true, "education": true, "experience": true, "training": true,
"award": true, "conference": true, "award": true, "conference": true,
@ -54,12 +54,6 @@ func (a *API) adminUpdateDoctor(w http.ResponseWriter, r *http.Request, u *stora
fail(w, http.StatusUnprocessableEntity, "validation", "Рейтинг должен быть от 1,0 до 5,0") fail(w, http.StatusUnprocessableEntity, "validation", "Рейтинг должен быть от 1,0 до 5,0")
return return
} }
// Medflex без ссылки работать не будет — не даём сохранить заведомо мёртвую кнопку.
if in.Booking == "medflex" && strings.TrimSpace(in.MedflexUser) == "" {
fail(w, http.StatusUnprocessableEntity, "validation",
"Для записи через Medflex нужна ссылка")
return
}
oldSlug, err := a.store.PublishedDoctorSlug(r.Context(), id) oldSlug, err := a.store.PublishedDoctorSlug(r.Context(), id)
if err != nil { if err != nil {
handleErr(w, err) handleErr(w, err)

View File

@ -93,7 +93,7 @@ const DoctorDash = {
['Основные сведения', ({ d }) => !!(d.last_name && d.first_name && d.position && d.specialties)], ['Основные сведения', ({ d }) => !!(d.last_name && d.first_name && d.position && d.specialties)],
['Фото', ({ d }) => !!d.photo], ['Фото', ({ d }) => !!d.photo],
['Стаж', ({ d }) => d.practice_start_year != null], ['Стаж', ({ d }) => d.practice_start_year != null],
['Онлайн-запись', ({ d }) => d.booking_provider === 'medflex'], ['Онлайн-запись', ({ d }) => d.booking_provider === 'native'],
['Категория', ({ d }) => !!d.category], ['Категория', ({ d }) => !!d.category],
['«О враче»', ({ d }) => !!d.bio], ['«О враче»', ({ d }) => !!d.bio],
], ],
@ -172,7 +172,7 @@ const DoctorDash = {
['Образование', cnt(({ q }) => q.education > 0), (v) => `заполнено у ${v}`], ['Образование', cnt(({ q }) => q.education > 0), (v) => `заполнено у ${v}`],
['Награды', cnt(({ q }) => q.awards > 0), (v) => `заполнено у ${v}`], ['Награды', cnt(({ q }) => q.awards > 0), (v) => `заполнено у ${v}`],
['Конференции', cnt(({ q }) => q.conferences > 0), (v) => `заполнено у ${v}`], ['Конференции', cnt(({ q }) => q.conferences > 0), (v) => `заполнено у ${v}`],
['Онлайн-запись Medflex', cnt(({ d }) => d.booking_provider === 'medflex'), (v) => `подключена у ${v}`, 'no-medflex'], ['Онлайн-запись 1С', cnt(({ d }) => d.booking_provider === 'native'), (v) => `подключена у ${v}`, 'no-booking'],
['Услуги врача', cnt(({ d }) => d.service_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не указаны', 'no-services'], ['Услуги врача', cnt(({ d }) => d.service_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не указаны', 'no-services'],
['Филиалы приёма', cnt(({ d }) => d.branch_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не заполнены', 'no-branches'], ['Филиалы приёма', cnt(({ d }) => d.branch_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не заполнены', 'no-branches'],
['Отзывы', withReviews, () => (pending + live ['Отзывы', withReviews, () => (pending + live
@ -208,7 +208,7 @@ const DoctorDash = {
'Онлайн-запись', 'Услуг', 'Филиалов', 'Отзывов на проверке', 'Отзывов опубликовано', 'Заявок']]; 'Онлайн-запись', 'Услуг', 'Филиалов', 'Отзывов на проверке', 'Отзывов опубликовано', 'Заявок']];
for (const { d, q } of DoctorDash.rows()) { for (const { d, q } of DoctorDash.rows()) {
rows.push([fullName(d), d.specialties, yes(d.photo), d.practice_start_year ? new Date().getFullYear() - d.practice_start_year + ' лет' : 'нет', rows.push([fullName(d), d.specialties, yes(d.photo), d.practice_start_year ? new Date().getFullYear() - d.practice_start_year + ' лет' : 'нет',
d.category || 'нет', yes(d.bio), q.education || 0, q.awards || 0, q.conferences || 0, d.booking_provider === 'medflex' ? 'Medflex' : 'заявка', d.category || 'нет', yes(d.bio), q.education || 0, q.awards || 0, q.conferences || 0, d.booking_provider === 'native' ? '1С' : 'заявка',
d.service_ids.length, d.branch_ids.length, q.reviews_pending || 0, q.reviews_published || 0, q.leads || 0]); d.service_ids.length, d.branch_ids.length, q.reviews_pending || 0, q.reviews_published || 0, q.leads || 0]);
} }
const esc = (v) => { const x = String(v ?? ''); return /[";\n]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; }; const esc = (v) => { const x = String(v ?? ''); return /[";\n]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; };
@ -224,7 +224,7 @@ $('#dd-spec').onchange = () => DoctorDash.render();
window.addEventListener('resize', debounce(() => { if (DoctorDash.q && !$('#tab-doctors-dash').hidden) DoctorDash.chart(DoctorDash.rows()); }, 150)); window.addEventListener('resize', debounce(() => { if (DoctorDash.q && !$('#tab-doctors-dash').hidden) DoctorDash.chart(DoctorDash.rows()); }, 150));
{ {
const acts = [['users', 'Все врачи', () => DoctorDash.toList('')], ['check', 'Проверить карточки', () => DoctorDash.toList('incomplete')], const acts = [['users', 'Все врачи', () => DoctorDash.toList('')], ['check', 'Проверить карточки', () => DoctorDash.toList('incomplete')],
['chat', 'Отзывы', () => go('reviews')], ['calendar', 'Онлайн-запись', () => DoctorDash.toList('no-medflex')], ['chat', 'Отзывы', () => go('reviews')], ['calendar', 'Онлайн-запись', () => DoctorDash.toList('no-booking')],
['download', 'Экспорт отчёта', () => DoctorDash.exportCSV()]]; ['download', 'Экспорт отчёта', () => DoctorDash.exportCSV()]];
const bar = $('#dd-actions'); const bar = $('#dd-actions');
for (const [icon, label, fn] of acts) { for (const [icon, label, fn] of acts) {

View File

@ -44,7 +44,7 @@ const Doctors = {
(f !== 'no-services' || !d.service_ids.length) && (f !== 'no-branches' || !d.branch_ids.length) && (f !== 'no-services' || !d.service_ids.length) && (f !== 'no-branches' || !d.branch_ids.length) &&
(f !== 'no-photo' || !d.photo) && (f !== 'no-photo' || !d.photo) &&
(f !== 'no-experience' || d.practice_start_year == null) && (f !== 'no-category' || !d.category) && (f !== 'no-experience' || d.practice_start_year == null) && (f !== 'no-category' || !d.category) &&
(f !== 'no-bio' || !d.bio) && (f !== 'no-medflex' || d.booking_provider !== 'medflex') && (f !== 'no-bio' || !d.bio) && (f !== 'no-booking' || d.booking_provider !== 'native') &&
(f !== 'incomplete' || !d.photo || d.practice_start_year == null || (!d.bio && !d.bio_count)) && (f !== 'incomplete' || !d.photo || d.practice_start_year == null || (!d.bio && !d.bio_count)) &&
(!f.startsWith('sp:') || String(d.specialty_id) === f.slice(3))); (!f.startsWith('sp:') || String(d.specialty_id) === f.slice(3)));
const { key, dir } = Doctors.sort; const { key, dir } = Doctors.sort;
@ -150,7 +150,7 @@ const Doctors = {
const body = { const body = {
slug: d.slug, last_name: d.last_name, first_name: d.first_name, middle_name: d.middle_name, slug: d.slug, last_name: d.last_name, first_name: d.first_name, middle_name: d.middle_name,
position: d.position, category: d.category, degree: d.degree, practice_start_year: d.practice_start_year, rating: d.rating, position: d.position, category: d.category, degree: d.degree, practice_start_year: d.practice_start_year, rating: d.rating,
photo_id: d.photo_id, quote: d.quote, bio: d.bio, booking_provider: d.booking_provider, medflex_user: d.medflex_user, photo_id: d.photo_id, quote: d.quote, bio: d.bio, booking_provider: d.booking_provider,
sort: d.sort, published: d.published, absent: d.absent, show_on_home: d.show_on_home, sort: d.sort, published: d.published, absent: d.absent, show_on_home: d.show_on_home,
seo_title: d.seo_title, seo_description: d.seo_description, seo_h1: d.seo_h1, ...change, seo_title: d.seo_title, seo_description: d.seo_description, seo_h1: d.seo_h1, ...change,
}; };
@ -192,7 +192,7 @@ const Doctors = {
const base = [['', 'Все врачи'], ['published', 'На сайте'], ['hidden', 'Скрытые'], ['absent', 'Не принимают'], const base = [['', 'Все врачи'], ['published', 'На сайте'], ['hidden', 'Скрытые'], ['absent', 'Не принимают'],
['no-services', 'Без услуг'], ['no-branches', 'Без филиала'], ['no-photo', 'Без фото'], ['no-services', 'Без услуг'], ['no-branches', 'Без филиала'], ['no-photo', 'Без фото'],
['incomplete', 'Неполные карточки'], ['no-experience', 'Без стажа'], ['no-category', 'Без категории'], ['incomplete', 'Неполные карточки'], ['no-experience', 'Без стажа'], ['no-category', 'Без категории'],
['no-bio', 'Без «О враче»'], ['no-medflex', 'Без онлайн-записи']]; ['no-bio', 'Без «О враче»'], ['no-booking', 'Без онлайн-записи 1С']];
for (const [v, t] of base) sel.append(el('option', { value: v, textContent: t })); for (const [v, t] of base) sel.append(el('option', { value: v, textContent: t }));
const grp = el('optgroup', { label: 'Специальность' }); const grp = el('optgroup', { label: 'Специальность' });
for (const s of specialtiesCache.filter((s) => s.doctors)) grp.append(el('option', { value: 'sp:' + s.id, textContent: `${s.name_plural} (${s.doctors})` })); for (const s of specialtiesCache.filter((s) => s.doctors)) grp.append(el('option', { value: 'sp:' + s.id, textContent: `${s.name_plural} (${s.doctors})` }));

View File

@ -1717,8 +1717,7 @@ function editDoctor(d) {
{ key: 'bio', label: 'О враче — абзацы через пустую строку, пункты списка начинайте с «•»', value: d.bio, type: 'textarea', rows: 8 }, { key: 'bio', label: 'О враче — абзацы через пустую строку, пункты списка начинайте с «•»', value: d.bio, type: 'textarea', rows: 8 },
{ key: 'quote', label: 'Цитата врача (необязательно)', value: d.quote, type: 'textarea', rows: 2 }, { key: 'quote', label: 'Цитата врача (необязательно)', value: d.quote, type: 'textarea', rows: 2 },
{ key: 'booking_provider', label: 'Запись на приём', value: d.booking_provider || 'none', type: 'select', { key: 'booking_provider', label: 'Запись на приём', value: d.booking_provider || 'none', type: 'select',
options: [{ value: 'none', label: 'Заявка на сайте (перезвонит администратор)' }, { value: 'medflex', label: 'Онлайн-запись Medflex' }, { value: 'native', label: 'Своя запись' }] }, options: [{ value: 'none', label: 'Заявка на сайте (перезвонит администратор)' }, { value: 'native', label: 'Онлайн-запись через 1С' }] },
{ key: 'medflex_user', label: 'Ссылка Medflex (для онлайн-записи)', value: d.medflex_user },
{ key: 'absent', label: 'Временно не принимает (кнопка записи скрывается)', value: d.absent, type: 'checkbox' }, { key: 'absent', label: 'Временно не принимает (кнопка записи скрывается)', value: d.absent, type: 'checkbox' },
{ key: 'show_on_home', label: 'Показывать на главной', value: d.show_on_home, type: 'checkbox' }, { key: 'show_on_home', label: 'Показывать на главной', value: d.show_on_home, type: 'checkbox' },
{ key: 'sort', label: 'Порядок (меньше — выше в списке)', value: d.sort ?? 100, type: 'number' }, { key: 'sort', label: 'Порядок (меньше — выше в списке)', value: d.sort ?? 100, type: 'number' },

View File

@ -165,7 +165,7 @@ const Onec = {
if (!r.doctor_id) { if (!r.doctor_id) {
way.append(ocEl('span', { className: 'muted', textContent: 'сначала привяжите карточку' })); way.append(ocEl('span', { className: 'muted', textContent: 'сначала привяжите карточку' }));
} else { } else {
const label = { native: '1С', medflex: 'Medflex', none: 'заявка', '': 'заявка' }[r.booking] || r.booking; const label = { native: '1С', none: 'заявка', '': 'заявка' }[r.booking] || 'заявка';
way.append(ocEl('span', { className: 'pill ' + (bookingNative ? '' : 'grey'), textContent: label })); way.append(ocEl('span', { className: 'pill ' + (bookingNative ? '' : 'grey'), textContent: label }));
const b = ocEl('button', { type: 'button', className: 'btn ghost oc-small', textContent: bookingNative ? 'Отключить' : 'Включить запись через 1С' }); const b = ocEl('button', { type: 'button', className: 'btn ghost oc-small', textContent: bookingNative ? 'Отключить' : 'Включить запись через 1С' });
b.onclick = () => Onec.setBooking(r, !bookingNative); b.onclick = () => Onec.setBooking(r, !bookingNative);

View File

@ -0,0 +1,24 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"dana/internal/storage"
)
func TestDoctorRejectsRemovedExternalBookingProvider(t *testing.T) {
app := &API{}
r := httptest.NewRequest(http.MethodPut, "/api/v1/admin/doctors/1", strings.NewReader(`{"last_name":"Тест","slug":"test","booking_provider":"medflex","medflex_user":"https://booking.medflex.ru/example"}`))
r.SetPathValue("id", "1")
w := httptest.NewRecorder()
app.adminUpdateDoctor(w, r, &storage.AdminUser{})
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("removed provider accepted: %d %s", w.Code, w.Body.String())
}
if len(bookingProviders) != 2 || !bookingProviders["native"] || !bookingProviders["none"] {
t.Fatal("only on-site appointment methods should remain")
}
}

View File

@ -80,8 +80,7 @@ type DoctorService struct {
} }
type Booking struct { type Booking struct {
Provider string `json:"provider"` // none | native | medflex Provider string `json:"provider"` // none | native
URL string `json:"url,omitempty"`
} }
type BioEntry struct { type BioEntry struct {

View File

@ -22,7 +22,6 @@ type AdminDoctor struct {
SpecialtyID *int64 `json:"specialty_id"` SpecialtyID *int64 `json:"specialty_id"`
SpecialtySlug string `json:"specialty_slug"` SpecialtySlug string `json:"specialty_slug"`
Booking string `json:"booking_provider"` Booking string `json:"booking_provider"`
MedflexUser string `json:"medflex_user"`
Sort int `json:"sort"` Sort int `json:"sort"`
Published bool `json:"published"` Published bool `json:"published"`
Absent bool `json:"absent"` Absent bool `json:"absent"`
@ -48,7 +47,7 @@ func (s *Storage) AdminDoctors(ctx context.Context, q string) ([]AdminDoctor, er
WHERE ds.doctor_id = d.id WHERE ds.doctor_id = d.id
), ''), ), ''),
ps.id, COALESCE(ps.slug, ''), ps.id, COALESCE(ps.slug, ''),
d.booking_provider, d.medflex_user, d.sort, d.published, d.booking_provider, d.sort, d.published,
d.absent, d.show_on_home, d.absent, d.show_on_home,
(SELECT count(*) FROM doctor_bio_entries e WHERE e.doctor_id = d.id), (SELECT count(*) FROM doctor_bio_entries e WHERE e.doctor_id = d.id),
d.seo_title, d.seo_description, d.seo_h1, d.seo_title, d.seo_description, d.seo_h1,
@ -79,11 +78,14 @@ func (s *Storage) AdminDoctors(ctx context.Context, q string) ([]AdminDoctor, er
&a.Position, &a.Category, &a.Degree, &a.PracticeYear, &a.Rating, &a.Position, &a.Category, &a.Degree, &a.PracticeYear, &a.Rating,
&a.PhotoID, &a.Photo, &a.Quote, &a.Bio, &a.Specialties, &a.PhotoID, &a.Photo, &a.Quote, &a.Bio, &a.Specialties,
&a.SpecialtyID, &a.SpecialtySlug, &a.SpecialtyID, &a.SpecialtySlug,
&a.Booking, &a.MedflexUser, &a.Sort, &a.Published, &a.Booking, &a.Sort, &a.Published,
&a.Absent, &a.ShowOnHome, &a.BioCount, &a.Absent, &a.ShowOnHome, &a.BioCount,
&a.SEOTitle, &a.SEODesc, &a.SEOH1, &a.ServiceIDs, &a.BranchIDs); err != nil { &a.SEOTitle, &a.SEODesc, &a.SEOH1, &a.ServiceIDs, &a.BranchIDs); err != nil {
return nil, err return nil, err
} }
if a.Booking != "native" {
a.Booking = "none"
}
out = append(out, a) out = append(out, a)
} }
return out, rows.Err() return out, rows.Err()
@ -103,7 +105,6 @@ type DoctorUpdate struct {
Quote string `json:"quote"` Quote string `json:"quote"`
Bio string `json:"bio"` Bio string `json:"bio"`
Booking string `json:"booking_provider"` Booking string `json:"booking_provider"`
MedflexUser string `json:"medflex_user"`
Sort int `json:"sort"` Sort int `json:"sort"`
Published bool `json:"published"` Published bool `json:"published"`
Absent bool `json:"absent"` Absent bool `json:"absent"`
@ -123,7 +124,7 @@ func (s *Storage) AdminUpdateDoctor(ctx context.Context, id int64, in DoctorUpda
WHERE id=$1 AND deleted_at IS NULL`, WHERE id=$1 AND deleted_at IS NULL`,
id, in.Slug, in.LastName, in.FirstName, in.MiddleName, id, in.Slug, in.LastName, in.FirstName, in.MiddleName,
in.Position, in.Category, in.Degree, in.PracticeYear, in.Rating, in.Position, in.Category, in.Degree, in.PracticeYear, in.Rating,
in.Quote, in.Bio, in.Booking, in.MedflexUser, in.Quote, in.Bio, in.Booking, "",
in.Sort, in.Published, in.Absent, in.ShowOnHome, in.Sort, in.Published, in.Absent, in.ShowOnHome,
in.SEOTitle, in.SEODesc, in.SEOH1, in.PhotoID) in.SEOTitle, in.SEODesc, in.SEOH1, in.PhotoID)
if err != nil { if err != nil {

View File

@ -0,0 +1,74 @@
package storage
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
)
func TestDoctorExternalBookingCannotLeakOrReturn(t *testing.T) {
dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN")
if dsn == "" {
t.Skip("requires disposable autonomy database")
}
ctx := context.Background()
s, err := New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err = s.Migrate(ctx); err != nil {
t.Fatal(err)
}
slug := fmt.Sprintf("legacy-external-%d", time.Now().UnixNano())
var id int64
if err = s.Pool().QueryRow(ctx, `INSERT INTO doctors(slug,last_name,first_name,booking_provider,medflex_user) VALUES($1,'Тест','Врач','medflex','https://booking.medflex.ru/legacy') RETURNING id`, slug).Scan(&id); err != nil {
t.Fatal(err)
}
d, err := s.DoctorBySlug(ctx, slug)
if err != nil {
t.Fatal(err)
}
public, _ := json.Marshal(d)
if d.Booking.Provider != "none" || strings.Contains(string(public), "medflex") {
t.Fatalf("legacy link leaked: %s", public)
}
rows, err := s.AdminDoctors(ctx, slug)
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatal("slug must not match the name filter")
}
rows, err = s.AdminDoctors(ctx, "")
if err != nil {
t.Fatal(err)
}
found := false
for _, row := range rows {
if row.ID == id {
found = true
admin, _ := json.Marshal(row)
if row.Booking != "none" || strings.Contains(string(admin), "medflex") {
t.Fatalf("legacy option leaked in admin: %s", admin)
}
}
}
if !found {
t.Fatal("doctor missing from admin")
}
if err = s.AdminUpdateDoctor(ctx, id, DoctorUpdate{Slug: slug, LastName: "Тест", FirstName: "Врач", Booking: "none", Published: true}); err != nil {
t.Fatal(err)
}
var link string
if err = s.Pool().QueryRow(ctx, `SELECT medflex_user FROM doctors WHERE id=$1`, id).Scan(&link); err != nil {
t.Fatal(err)
}
if link != "" {
t.Fatal("editing doctor restored an external link")
}
}

View File

@ -16,7 +16,7 @@ import (
// приоритет, а при пустом значении используется средняя оценка отзывов. // приоритет, а при пустом значении используется средняя оценка отзывов.
const doctorColumns = `d.id, d.slug, d.last_name, d.first_name, d.middle_name, d.position, 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.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.languages, d.accepts_kids_from, d.booking_provider, d.absent,
d.seo_title, d.seo_description, d.seo_h1, d.seo_title, d.seo_description, d.seo_h1,
COALESCE(d.rating_override::float8, COALESCE(d.rating_override::float8,
(SELECT round(avg(r.rating)::numeric, 1)::float8 FROM reviews r (SELECT round(avg(r.rating)::numeric, 1)::float8 FROM reviews r
@ -24,15 +24,12 @@ const doctorColumns = `d.id, d.slug, d.last_name, d.first_name, d.middle_name, d
(SELECT count(*) FROM reviews r (SELECT count(*) FROM reviews r
WHERE r.doctor_id = d.id AND r.status = 'published')` 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) { func scanDoctor(row pgx.Row) (domain.Doctor, error) {
var d domain.Doctor var d domain.Doctor
var startYear *int var startYear *int
var medflexUser string
err := row.Scan(&d.ID, &d.Slug, &d.LastName, &d.FirstName, &d.MiddleName, &d.Position, 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.Category, &d.Degree, &startYear, &d.Photo, &d.Quote,
&d.Languages, &d.KidsFrom, &d.Booking.Provider, &medflexUser, &d.Absent, &d.Languages, &d.KidsFrom, &d.Booking.Provider, &d.Absent,
&d.SEO.Title, &d.SEO.Description, &d.SEO.H1, &d.SEO.Title, &d.SEO.Description, &d.SEO.H1,
&d.Rating, &d.ReviewCount) &d.Rating, &d.ReviewCount)
if err != nil { if err != nil {
@ -46,14 +43,8 @@ func scanDoctor(row pgx.Row) (domain.Doctor, error) {
d.Experience = &years d.Experience = &years
} }
} }
if d.Booking.Provider == "medflex" && medflexUser != "" { if d.Booking.Provider != "native" {
// При переносе со старого сайта в поле попала ссылка целиком; d.Booking.Provider = "none"
// через админку можно ввести и только идентификатор.
if strings.HasPrefix(medflexUser, "http://") || strings.HasPrefix(medflexUser, "https://") {
d.Booking.URL = medflexUser
} else {
d.Booking.URL = medflexBase + medflexUser
}
} }
return d, nil return d, nil
} }

View File

@ -9,16 +9,9 @@ export function roleLabel(doctor) {
return doctor.position || doctor.specialties?.[0]?.name || ''; return doctor.position || doctor.specialties?.[0]?.name || '';
} }
// Запись к врачу: у кого настроен Medflex — ведём туда, у остальных открываем форму заявки. // Запись всегда остаётся на сайте: окна 1С либо обычная форма заявки.
export function BookButton({ doctor, className = '', children = 'Записаться' }) { export function BookButton({ doctor, className = '', children = 'Записаться' }) {
const { open } = useLeadModal(); const { open } = useLeadModal();
if (doctor.booking?.provider === 'medflex' && doctor.booking.url) {
return (
<a className={className} href={doctor.booking.url} target="_blank" rel="noreferrer">
{children}
</a>
);
}
return ( return (
<button type="button" className={className} onClick={() => open({ form: 'appointment', doctor })}> <button type="button" className={className} onClick={() => open({ form: 'appointment', doctor })}>
{children} {children}