From da3b074c9835e8cd7b3a95730bc72636445ec576 Mon Sep 17 00:00:00 2001 From: Dana Medical Developer Date: Thu, 22 Oct 2026 09:44:04 +0000 Subject: [PATCH] fix(booking): filter specialists and real slots by chosen date --- internal/api/api.go | 1 + internal/api/booking_availability.go | 21 +++++++ internal/api/onec_all_dates_test.go | 25 ++++++++ internal/storage/booking_availability.go | 37 ++++++++++++ web/scripts/test-booking-flow.mjs | 28 +++++++-- web/src/api.js | 1 + web/src/components/LeadModal.jsx | 73 ++++++++++++++++++++---- web/src/components/NativeBooking.jsx | 7 ++- 8 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 internal/api/booking_availability.go create mode 100644 internal/storage/booking_availability.go diff --git a/internal/api/api.go b/internal/api/api.go index 155c67f..a9b3186 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -90,6 +90,7 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("GET /api/v1/doctors", a.doctors) mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor) mux.HandleFunc("GET /api/v1/doctors/{slug}/slots", a.doctorSlots) + mux.HandleFunc("GET /api/v1/booking/availability", a.bookingAvailability) mux.HandleFunc("GET /api/v1/prices", a.prices) mux.HandleFunc("GET /api/v1/price-sections", a.priceSections) diff --git a/internal/api/booking_availability.go b/internal/api/booking_availability.go new file mode 100644 index 0000000..a15f274 --- /dev/null +++ b/internal/api/booking_availability.go @@ -0,0 +1,21 @@ +package api + +import ( + "net/http" + + "dana/internal/storage" +) + +func (a *API) bookingAvailability(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !a.onecAutonomousEnabled() || !a.store.OnecFresh(r.Context(), onecStaleAfter) { + ok(w, map[string]any{"enabled": false, "days": []storage.BookingDay{}}, nil) + return + } + days, err := a.store.PublicBookingDays(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, map[string]any{"enabled": true, "days": days}, nil) +} diff --git a/internal/api/onec_all_dates_test.go b/internal/api/onec_all_dates_test.go index 34fd5fe..668ed33 100644 --- a/internal/api/onec_all_dates_test.go +++ b/internal/api/onec_all_dates_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http/httptest" "os" + "slices" "testing" "time" @@ -74,6 +75,29 @@ func TestAutonomousAllActualFutureDates(t *testing.T) { if !result.Data.Enabled || len(result.Data.Slots) != 1 || !result.Data.Slots[0].Start.Equal(far) { t.Fatalf("actual distant slot hidden: %s", shown.Body.String()) } + checkAvailability := func(want bool) { + t.Helper() + w := call("GET", "/api/v1/booking/availability", nil, 200) + var availability struct { + Data struct { + Enabled bool `json:"enabled"` + Days []storage.BookingDay `json:"days"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &availability); err != nil { + t.Fatal(err) + } + found := false + for _, day := range availability.Data.Days { + if day.Date == far.In(moscow).Format("2006-01-02") && slices.Contains(day.DoctorIDs, doctorID) { + found = true + } + } + if !availability.Data.Enabled || found != want { + t.Fatalf("date-to-doctor availability mismatch: %s", w.Body.String()) + } + } + checkAvailability(true) creation := appointmentInput{DoctorSlug: doctor, Start: far.Format(time.RFC3339), Name: "Test", Phone: "79990000001", Consent: true, RequestID: doctor} made := call("POST", "/api/v1/appointments", creation, 201) if !bytes.Contains(made.Body.Bytes(), []byte(`"mode":"pending"`)) { @@ -88,6 +112,7 @@ func TestAutonomousAllActualFutureDates(t *testing.T) { if len(result.Data.Slots) != 0 { t.Fatal("reserved distant slot reopened") } + checkAvailability(false) // Removing the calendar cutoff must not permit invented dates. creation.Start = far.Add(time.Hour).Format(time.RFC3339) creation.RequestID = doctor + "-invented" diff --git a/internal/storage/booking_availability.go b/internal/storage/booking_availability.go new file mode 100644 index 0000000..3311de9 --- /dev/null +++ b/internal/storage/booking_availability.go @@ -0,0 +1,37 @@ +package storage + +import "context" + +type BookingDay struct { + Date string `json:"date"` + DoctorIDs []int64 `json:"doctor_ids"` +} + +// PublicBookingDays returns only dates and public doctor IDs, never patient data. +// Keep the occupancy rules identical to FreeOnecSlots, including cross-branch reservations. +func (s *Storage) PublicBookingDays(ctx context.Context) ([]BookingDay, error) { + rows, err := s.pool.Query(ctx, ` + SELECT to_char(sl.start_at AT TIME ZONE 'Europe/Moscow','YYYY-MM-DD'), + array_agg(DISTINCT d.id ORDER BY d.id) + FROM onec_slots sl JOIN onec_doctors od ON od.onec_id=sl.doctor_onec_id + JOIN doctors d ON d.id=od.doctor_id + WHERE sl.status='free' AND sl.start_at>now() AND od.active + AND d.published AND d.deleted_at IS NULL AND NOT d.absent AND d.booking_provider='native' + AND NOT EXISTS(SELECT 1 FROM onec_appointments a + WHERE a.doctor_onec_id=sl.doctor_onec_id AND a.status IN('pending','booked','arrived') + AND a.start_atsl.start_at) + GROUP BY 1 ORDER BY 1`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []BookingDay{} + for rows.Next() { + var day BookingDay + if err := rows.Scan(&day.Date, &day.DoctorIDs); err != nil { + return nil, err + } + out = append(out, day) + } + return out, rows.Err() +} diff --git a/web/scripts/test-booking-flow.mjs b/web/scripts/test-booking-flow.mjs index 33a59c3..161fbcf 100644 --- a/web/scripts/test-booking-flow.mjs +++ b/web/scripts/test-booking-flow.mjs @@ -8,6 +8,7 @@ Object.assign(globalThis, { window, document: window.document, HTMLElement: wind window.location = new URL('https://booking-test.invalid/'); const { default: React, act } = await import('react'); const { createRoot } = await import('react-dom/client'); +const { Simulate } = await import('react-dom/test-utils'); const { MemoryRouter } = await import('react-router-dom'); const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' }); const { default: LeadModal } = await vite.ssrLoadModule('/src/components/LeadModal.jsx'); @@ -19,6 +20,10 @@ const doctors = [ ]; const pending = []; api.doctors = async () => ({ data: doctors }); +api.bookingAvailability = async () => ({ data: { enabled: true, days: [ + { date: '2070-10-23', doctor_ids: [1, 2] }, + { date: '2070-10-24', doctor_ids: [2] }, +] } }); api.doctorSlots = (slug) => new Promise((resolve, reject) => pending.push({ slug, resolve, reject })); api.createLead = api.createAppointment = () => { throw new Error('Real submissions are forbidden in this test'); }; const container = document.getElementById('root'); @@ -43,10 +48,10 @@ async function reply(request, slots, enabled = true) { try { await render(); assert.equal(pending.length, 0, 'No schedule requests before selecting a doctor'); - assert.equal(container.querySelector('input[type="date"]'), null); + assert.ok(container.querySelector('input[type="date"]')); assert.equal(container.querySelector('input[type="tel"]'), null); assert.deepEqual(times(), []); - assert.match(text(), /Сначала выберите врача/); + assert.match(text(), /Выберите дату или врача/); await chooseDoctor(1); assert.equal(pending[0].slug, 'first'); @@ -73,7 +78,7 @@ try { await chooseDoctor(3); assert.match(text(), /Свободные окна этого врача сейчас недоступны/); assert.deepEqual(times(), []); - assert.equal(container.querySelector('input[type="date"]'), null, 'Fallback cannot offer invented dates'); + assert.equal(container.querySelectorAll('.slot-chip--time').length, 0, 'Fallback cannot offer invented times'); assert.ok(container.querySelector('input[type="tel"]')); await chooseDoctor(2); await act(async () => pending[3].reject(new Error('Schedule temporarily unavailable'))); @@ -86,6 +91,21 @@ try { assert.deepEqual(times(), ['16:15']); assert.equal(container.querySelector('input[type="tel"]'), null, 'A stale initial slot must not be preselected'); + await act(async () => root.render(null)); + await render(); + await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-24' } })); + assert.deepEqual([...container.querySelector('select').options].map((option) => option.value), ['', '2'], 'Only doctors free on the selected date appear'); + await chooseDoctor(2); + await reply(pending.at(-1), [free('09:00'), { ...free('14:15'), start: '2070-10-24T14:15:00+03:00' }]); + assert.deepEqual(times(), ['14:15'], 'Selected date restricts the actual time list too'); + await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-25' } })); + assert.deepEqual(times(), []); + assert.equal(container.querySelector('select').value, '', 'Changing date clears the selected doctor and window'); + assert.equal(container.querySelector('select').options.length, 1); + assert.match(text(), /На эту дату свободных окон нет/); + await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '' } })); + assert.equal(container.querySelector('select').options.length, 4, 'Clearing date restores the doctor-first flow'); + await act(async () => root.render(null)); await render({ form: 'callback' }); assert.ok(container.querySelector('input[type="tel"]')); @@ -94,7 +114,7 @@ try { await render({ form: 'review' }); assert.equal(container.querySelectorAll('.lead-rating__star').length, 5); assert.ok(container.querySelector('select')); - console.log('PASS: doctor-first selection, actual slots, switching/reset, stale responses, fallback, callback and review'); + console.log('PASS: date-to-doctor filtering, actual times on selected date, reset/empty dates, doctor-first flow, stale responses, fallback, callback and review'); } finally { await act(async () => root.unmount()); await vite.close(); diff --git a/web/src/api.js b/web/src/api.js index d3684ed..354fba1 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -61,6 +61,7 @@ export const api = { waitCallStatus: (sessionId, o) => request('GET', `/auth/wait-call/status?session_id=${encodeURIComponent(sessionId)}`, o), // свободные окна врача из 1С и запись на конкретное окно doctorSlots: (slug, o) => request('GET', `/doctors/${encodeURIComponent(slug)}/slots`, o), + bookingAvailability: (o) => request('GET', '/booking/availability', o), createAppointment: (body) => request('POST', '/appointments', { body: { utm: attribution(), ...body } }).then((res) => { reachGoal('lead', { form: 'appointment' }); diff --git a/web/src/components/LeadModal.jsx b/web/src/components/LeadModal.jsx index 37fa925..65477ee 100644 --- a/web/src/components/LeadModal.jsx +++ b/web/src/components/LeadModal.jsx @@ -9,12 +9,13 @@ import { api } from '../api.js'; import { useSiteData } from '../context/SiteData.jsx'; import { formatPhoneInput, phoneComplete } from '../lib/phone.js'; import NativeBooking from './NativeBooking.jsx'; +import { todayKey } from '../lib/slots.js'; import './LeadModal.css'; const COPY = { appointment: { title: 'Записаться на приём', - subtitle: 'Выберите врача, затем свободную дату и время приёма', + subtitle: 'Выберите дату — покажем врачей со свободными окнами на этот день', }, callback: { title: 'Обратный звонок', @@ -44,6 +45,8 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, const [doctorsLoaded, setDoctorsLoaded] = useState(false); const [unavailableDoctorId, setUnavailableDoctorId] = useState(null); const [requestedSlot, setRequestedSlot] = useState(slot); + const [date, setDate] = useState(slot?.start?.slice(0, 10) || ''); + const [availability, setAvailability] = useState({ loading: true, days: [], error: false }); const [values, setValues] = useState({ name: '', phone: '', @@ -53,8 +56,11 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, consent: false, }); const [status, setStatus] = useState({ state: 'idle', message: '' }); - const selectedDoctor = doctors.find((d) => String(d.id) === values.doctorId) + const chosenDoctor = doctors.find((d) => String(d.id) === values.doctorId) || (doctor && String(doctor.id) === values.doctorId ? doctor : null); + const selectedDoctor = form === 'appointment' && date + && (availability.loading || !availability.days.find((day) => day.date === date)?.doctor_ids.includes(chosenDoctor?.id)) + ? null : chosenDoctor; const native = form === 'appointment' && selectedDoctor?.booking?.provider === 'native' && unavailableDoctorId !== selectedDoctor.id; const onNativeUnavailable = useCallback( @@ -75,6 +81,28 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, })); }; + const changeDate = (e) => { + setDate(e.target.value); + setRequestedSlot(undefined); + setUnavailableDoctorId(null); + setValues((previous) => ({ ...previous, doctorId: '' })); + setStatus({ state: 'idle', message: '' }); + }; + + useEffect(() => { + if (form !== 'appointment') return undefined; + const ctrl = new AbortController(); + setAvailability((previous) => ({ ...previous, loading: true, error: false })); + api.bookingAvailability({ signal: ctrl.signal }) + .then(({ data }) => { + if (!ctrl.signal.aborted) setAvailability({ loading: false, days: data?.enabled ? data.days || [] : [], error: false }); + }) + .catch(() => { + if (!ctrl.signal.aborted) setAvailability({ loading: false, days: [], error: true }); + }); + return () => ctrl.abort(); + }, [form, date]); + useEffect(() => { if (!needsDoctor) return undefined; const ctrl = new AbortController(); @@ -127,8 +155,13 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, }, [onClose]); const doctorOptions = useMemo( - () => [...doctors].sort((a, b) => a.full_name.localeCompare(b.full_name, 'ru')), - [doctors], + () => { + const allowed = date && form === 'appointment' + ? new Set(availability.days.find((day) => day.date === date)?.doctor_ids || []) : null; + return [...doctors].filter((d) => !allowed || allowed.has(d.id)) + .sort((a, b) => a.full_name.localeCompare(b.full_name, 'ru')); + }, + [doctors, date, form, availability.days], ); const nameRequired = form !== 'callback'; @@ -206,13 +239,28 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, + {form === 'appointment' && status.state !== 'done' && ( + + )} + {form === 'appointment' && status.state !== 'done' && (