fix(booking): filter specialists and real slots by chosen date
This commit is contained in:
parent
97fc02778e
commit
da3b074c98
@ -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)
|
||||
|
||||
|
||||
21
internal/api/booking_availability.go
Normal file
21
internal/api/booking_availability.go
Normal 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)
|
||||
}
|
||||
@ -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"
|
||||
|
||||
37
internal/storage/booking_availability.go
Normal file
37
internal/storage/booking_availability.go
Normal 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()
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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,
|
||||
</button>
|
||||
</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' && (
|
||||
<label className="lead-field lead-field--doctor">
|
||||
<span className="lead-field__label">Специалист</span>
|
||||
<span className="lead-field__select">
|
||||
<select ref={firstFieldRef} className="lead-field__control" value={values.doctorId} onChange={set('doctorId')}>
|
||||
<option value="">{doctorsLoaded ? 'Выберите врача' : 'Загружаем список врачей…'}</option>
|
||||
{doctor && !doctorOptions.some((d) => d.id === doctor.id) && (
|
||||
<select className="lead-field__control" value={values.doctorId} onChange={set('doctorId')} disabled={Boolean(date && availability.loading)}>
|
||||
<option value="">{!doctorsLoaded || (date && availability.loading) ? 'Загружаем список врачей…' : 'Выберите врача'}</option>
|
||||
{doctor && !date && !doctorOptions.some((d) => d.id === doctor.id) && (
|
||||
<option value={doctor.id}>{doctor.full_name}</option>
|
||||
)}
|
||||
{doctorOptions.map((d) => (
|
||||
@ -228,12 +276,17 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
|
||||
{form === 'appointment' && !selectedDoctor ? (
|
||||
<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>
|
||||
) : 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' ? (
|
||||
<div className="lead-modal__done" role="status">
|
||||
<p>{status.message}</p>
|
||||
|
||||
@ -53,7 +53,7 @@ export function NextSlots({ doctor, onPick, limit = 6 }) {
|
||||
|
||||
// Запись в два шага: 1) окно, 2) контакты, подтверждение звонком и согласие.
|
||||
// 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 [slots, setSlots] = useState([]);
|
||||
const [services, setServices] = useState([]);
|
||||
@ -76,7 +76,8 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
|
||||
.doctorSlots(doctor.slug, { signal: ctrl.signal })
|
||||
.then(({ data }) => {
|
||||
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) {
|
||||
unavailable.current();
|
||||
return;
|
||||
@ -91,7 +92,7 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
|
||||
if (!ctrl.signal.aborted && e.name !== 'AbortError') unavailable.current();
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, [doctor.slug]);
|
||||
}, [doctor.slug, filterDate]);
|
||||
|
||||
const branches = useMemo(() => {
|
||||
const m = new Map();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user