fix(booking): filter specialists and real slots by chosen date

This commit is contained in:
Dana Medical Developer 2026-10-22 09:44:04 +00:00
parent 97fc02778e
commit da3b074c98
8 changed files with 176 additions and 17 deletions

View File

@ -90,6 +90,7 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/doctors", a.doctors) mux.HandleFunc("GET /api/v1/doctors", a.doctors)
mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor) mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor)
mux.HandleFunc("GET /api/v1/doctors/{slug}/slots", a.doctorSlots) 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/prices", a.prices)
mux.HandleFunc("GET /api/v1/price-sections", a.priceSections) mux.HandleFunc("GET /api/v1/price-sections", a.priceSections)

View File

@ -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)
}

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"net/http/httptest" "net/http/httptest"
"os" "os"
"slices"
"testing" "testing"
"time" "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) { 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()) 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} 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) made := call("POST", "/api/v1/appointments", creation, 201)
if !bytes.Contains(made.Body.Bytes(), []byte(`"mode":"pending"`)) { if !bytes.Contains(made.Body.Bytes(), []byte(`"mode":"pending"`)) {
@ -88,6 +112,7 @@ func TestAutonomousAllActualFutureDates(t *testing.T) {
if len(result.Data.Slots) != 0 { if len(result.Data.Slots) != 0 {
t.Fatal("reserved distant slot reopened") t.Fatal("reserved distant slot reopened")
} }
checkAvailability(false)
// Removing the calendar cutoff must not permit invented dates. // Removing the calendar cutoff must not permit invented dates.
creation.Start = far.Add(time.Hour).Format(time.RFC3339) creation.Start = far.Add(time.Hour).Format(time.RFC3339)
creation.RequestID = doctor + "-invented" creation.RequestID = doctor + "-invented"

View File

@ -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_at<sl.end_at AND COALESCE(a.end_at,a.start_at+interval '30 minutes')>sl.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()
}

View File

@ -8,6 +8,7 @@ Object.assign(globalThis, { window, document: window.document, HTMLElement: wind
window.location = new URL('https://booking-test.invalid/'); window.location = new URL('https://booking-test.invalid/');
const { default: React, act } = await import('react'); const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client'); const { createRoot } = await import('react-dom/client');
const { Simulate } = await import('react-dom/test-utils');
const { MemoryRouter } = await import('react-router-dom'); const { MemoryRouter } = await import('react-router-dom');
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' }); const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
const { default: LeadModal } = await vite.ssrLoadModule('/src/components/LeadModal.jsx'); const { default: LeadModal } = await vite.ssrLoadModule('/src/components/LeadModal.jsx');
@ -19,6 +20,10 @@ const doctors = [
]; ];
const pending = []; const pending = [];
api.doctors = async () => ({ data: doctors }); 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.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'); }; api.createLead = api.createAppointment = () => { throw new Error('Real submissions are forbidden in this test'); };
const container = document.getElementById('root'); const container = document.getElementById('root');
@ -43,10 +48,10 @@ async function reply(request, slots, enabled = true) {
try { try {
await render(); await render();
assert.equal(pending.length, 0, 'No schedule requests before selecting a doctor'); 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.equal(container.querySelector('input[type="tel"]'), null);
assert.deepEqual(times(), []); assert.deepEqual(times(), []);
assert.match(text(), /Сначала выберите врача/); assert.match(text(), /Выберите дату или врача/);
await chooseDoctor(1); await chooseDoctor(1);
assert.equal(pending[0].slug, 'first'); assert.equal(pending[0].slug, 'first');
@ -73,7 +78,7 @@ try {
await chooseDoctor(3); await chooseDoctor(3);
assert.match(text(), /Свободные окна этого врача сейчас недоступны/); assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
assert.deepEqual(times(), []); 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"]')); assert.ok(container.querySelector('input[type="tel"]'));
await chooseDoctor(2); await chooseDoctor(2);
await act(async () => pending[3].reject(new Error('Schedule temporarily unavailable'))); await act(async () => pending[3].reject(new Error('Schedule temporarily unavailable')));
@ -86,6 +91,21 @@ try {
assert.deepEqual(times(), ['16:15']); assert.deepEqual(times(), ['16:15']);
assert.equal(container.querySelector('input[type="tel"]'), null, 'A stale initial slot must not be preselected'); 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 act(async () => root.render(null));
await render({ form: 'callback' }); await render({ form: 'callback' });
assert.ok(container.querySelector('input[type="tel"]')); assert.ok(container.querySelector('input[type="tel"]'));
@ -94,7 +114,7 @@ try {
await render({ form: 'review' }); await render({ form: 'review' });
assert.equal(container.querySelectorAll('.lead-rating__star').length, 5); assert.equal(container.querySelectorAll('.lead-rating__star').length, 5);
assert.ok(container.querySelector('select')); 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 { } finally {
await act(async () => root.unmount()); await act(async () => root.unmount());
await vite.close(); await vite.close();

View File

@ -61,6 +61,7 @@ export const api = {
waitCallStatus: (sessionId, o) => request('GET', `/auth/wait-call/status?session_id=${encodeURIComponent(sessionId)}`, o), waitCallStatus: (sessionId, o) => request('GET', `/auth/wait-call/status?session_id=${encodeURIComponent(sessionId)}`, o),
// свободные окна врача из 1С и запись на конкретное окно // свободные окна врача из 1С и запись на конкретное окно
doctorSlots: (slug, o) => request('GET', `/doctors/${encodeURIComponent(slug)}/slots`, o), doctorSlots: (slug, o) => request('GET', `/doctors/${encodeURIComponent(slug)}/slots`, o),
bookingAvailability: (o) => request('GET', '/booking/availability', o),
createAppointment: (body) => createAppointment: (body) =>
request('POST', '/appointments', { body: { utm: attribution(), ...body } }).then((res) => { request('POST', '/appointments', { body: { utm: attribution(), ...body } }).then((res) => {
reachGoal('lead', { form: 'appointment' }); reachGoal('lead', { form: 'appointment' });

View File

@ -9,12 +9,13 @@ import { api } from '../api.js';
import { useSiteData } from '../context/SiteData.jsx'; import { useSiteData } from '../context/SiteData.jsx';
import { formatPhoneInput, phoneComplete } from '../lib/phone.js'; import { formatPhoneInput, phoneComplete } from '../lib/phone.js';
import NativeBooking from './NativeBooking.jsx'; import NativeBooking from './NativeBooking.jsx';
import { todayKey } from '../lib/slots.js';
import './LeadModal.css'; import './LeadModal.css';
const COPY = { const COPY = {
appointment: { appointment: {
title: 'Записаться на приём', title: 'Записаться на приём',
subtitle: 'Выберите врача, затем свободную дату и время приёма', subtitle: 'Выберите дату — покажем врачей со свободными окнами на этот день',
}, },
callback: { callback: {
title: 'Обратный звонок', title: 'Обратный звонок',
@ -44,6 +45,8 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
const [doctorsLoaded, setDoctorsLoaded] = useState(false); const [doctorsLoaded, setDoctorsLoaded] = useState(false);
const [unavailableDoctorId, setUnavailableDoctorId] = useState(null); const [unavailableDoctorId, setUnavailableDoctorId] = useState(null);
const [requestedSlot, setRequestedSlot] = useState(slot); 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({ const [values, setValues] = useState({
name: '', name: '',
phone: '', phone: '',
@ -53,8 +56,11 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
consent: false, consent: false,
}); });
const [status, setStatus] = useState({ state: 'idle', message: '' }); 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); || (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' const native = form === 'appointment' && selectedDoctor?.booking?.provider === 'native'
&& unavailableDoctorId !== selectedDoctor.id; && unavailableDoctorId !== selectedDoctor.id;
const onNativeUnavailable = useCallback( 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(() => { useEffect(() => {
if (!needsDoctor) return undefined; if (!needsDoctor) return undefined;
const ctrl = new AbortController(); const ctrl = new AbortController();
@ -127,8 +155,13 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
}, [onClose]); }, [onClose]);
const doctorOptions = useMemo( 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'; const nameRequired = form !== 'callback';
@ -206,13 +239,28 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
</button> </button>
</div> </div>
{form === 'appointment' && status.state !== 'done' && (
<label className="lead-field">
<span className="lead-field__label">Дата приёма</span>
<input
ref={firstFieldRef}
className="lead-field__control"
type="date"
min={todayKey()}
value={date}
onChange={changeDate}
/>
<span className="lead-modal__subtitle">Можно выбрать дату и увидеть только свободных в этот день врачей.</span>
</label>
)}
{form === 'appointment' && status.state !== 'done' && ( {form === 'appointment' && status.state !== 'done' && (
<label className="lead-field lead-field--doctor"> <label className="lead-field lead-field--doctor">
<span className="lead-field__label">Специалист</span> <span className="lead-field__label">Специалист</span>
<span className="lead-field__select"> <span className="lead-field__select">
<select ref={firstFieldRef} className="lead-field__control" value={values.doctorId} onChange={set('doctorId')}> <select className="lead-field__control" value={values.doctorId} onChange={set('doctorId')} disabled={Boolean(date && availability.loading)}>
<option value="">{doctorsLoaded ? 'Выберите врача' : 'Загружаем список врачей…'}</option> <option value="">{!doctorsLoaded || (date && availability.loading) ? 'Загружаем список врачей…' : 'Выберите врача'}</option>
{doctor && !doctorOptions.some((d) => d.id === doctor.id) && ( {doctor && !date && !doctorOptions.some((d) => d.id === doctor.id) && (
<option value={doctor.id}>{doctor.full_name}</option> <option value={doctor.id}>{doctor.full_name}</option>
)} )}
{doctorOptions.map((d) => ( {doctorOptions.map((d) => (
@ -228,12 +276,17 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
{form === 'appointment' && !selectedDoctor ? ( {form === 'appointment' && !selectedDoctor ? (
<p className="lead-modal__availability" role="status"> <p className="lead-modal__availability" role="status">
{doctorsLoaded && doctorOptions.length === 0 {date && availability.error
? 'Не удалось проверить расписание. Попробуйте выбрать дату ещё раз.'
: date && !availability.loading && doctorsLoaded && doctorOptions.length === 0
? 'На эту дату свободных окон нет. Выберите другую дату.'
: doctorsLoaded && doctorOptions.length === 0
? 'Список врачей сейчас недоступен. Позвоните в клинику, чтобы уточнить запись.' ? 'Список врачей сейчас недоступен. Позвоните в клинику, чтобы уточнить запись.'
: 'Сначала выберите врача — покажем только его свободные даты и время из 1С.'} : date ? 'Выберите врача из списка — покажем его свободные часы на выбранную дату.'
: 'Выберите дату или врача — покажем только реальные свободные окна из 1С.'}
</p> </p>
) : native ? ( ) : native ? (
<NativeBooking key={selectedDoctor.id} doctor={selectedDoctor} initialSlot={requestedSlot} onClose={onClose} onUnavailable={onNativeUnavailable} /> <NativeBooking key={`${selectedDoctor.id}:${date}`} doctor={selectedDoctor} filterDate={date} initialSlot={requestedSlot} onClose={onClose} onUnavailable={onNativeUnavailable} />
) : status.state === 'done' ? ( ) : status.state === 'done' ? (
<div className="lead-modal__done" role="status"> <div className="lead-modal__done" role="status">
<p>{status.message}</p> <p>{status.message}</p>

View File

@ -53,7 +53,7 @@ export function NextSlots({ doctor, onPick, limit = 6 }) {
// Запись в два шага: 1) окно, 2) контакты, подтверждение звонком и согласие. // Запись в два шага: 1) окно, 2) контакты, подтверждение звонком и согласие.
// onUnavailable — 1С не отвечает или окон нет: окно заявки возвращается к обычной форме. // onUnavailable — 1С не отвечает или окон нет: окно заявки возвращается к обычной форме.
export default function NativeBooking({ doctor, initialSlot, onClose, onUnavailable }) { export default function NativeBooking({ doctor, initialSlot, filterDate = '', onClose, onUnavailable }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [slots, setSlots] = useState([]); const [slots, setSlots] = useState([]);
const [services, setServices] = useState([]); const [services, setServices] = useState([]);
@ -76,7 +76,8 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
.doctorSlots(doctor.slug, { signal: ctrl.signal }) .doctorSlots(doctor.slug, { signal: ctrl.signal })
.then(({ data }) => { .then(({ data }) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const actualSlots = (data?.slots || []).filter((s) => s.status === 'free' && new Date(s.start).getTime() > Date.now()); const actualSlots = (data?.slots || []).filter((s) => s.status === 'free' && new Date(s.start).getTime() > Date.now()
&& (!filterDate || slotDate(s) === filterDate));
if (!data?.enabled || !actualSlots.length) { if (!data?.enabled || !actualSlots.length) {
unavailable.current(); unavailable.current();
return; return;
@ -91,7 +92,7 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
if (!ctrl.signal.aborted && e.name !== 'AbortError') unavailable.current(); if (!ctrl.signal.aborted && e.name !== 'AbortError') unavailable.current();
}); });
return () => ctrl.abort(); return () => ctrl.abort();
}, [doctor.slug]); }, [doctor.slug, filterDate]);
const branches = useMemo(() => { const branches = useMemo(() => {
const m = new Map(); const m = new Map();