diff --git a/deploy/booking-service-safety.md b/deploy/booking-service-safety.md new file mode 100644 index 0000000..fc4a75f --- /dev/null +++ b/deploy/booking-service-safety.md @@ -0,0 +1,46 @@ +# Услуги в публичной записи: временная защита от неверного справочника + +Проверка 23.10.2026: в `onec_services` у 175 врачей разных направлений сохранён +один и тот же набор из 18 идентификаторов/названий (3150 строк). В нём смешаны +анализы, рентген, хирургические и стоматологические процедуры. Это не +подтверждённая связь «врач → выполняемая услуга». Точное место, где формируется +неверный список в действующем отправителе 1С, этой проверкой не установлено. + +До сверки с 1С публичный `GET /api/v1/doctors/{slug}/slots` возвращает `services: []`, +форма не предлагает услуги и не отправляет `service_id`. Непустой `service_id` +из старой вкладки отклоняется до создания записи с кодом `service_unavailable`. +Выбор врача, реального свободного окна, филиала и очередь записи не меняются. +Импортированный справочник и прошлые записи не удаляются и не переписываются. + +Для возвращения выбора услуг нужны проверенные привязки к конкретному врачу +из 1С, проверка этих привязок перед созданием записи и регрессионные примеры +для стоматолога, ЛОРа и хирурга. Нельзя заменять их общим прайсом клиники или +угадывать исполнителя по ключевым словам. Это ограничение снимается отдельным +проверенным изменением; один новый снимок 1С сам по себе его не отключает. + +## Проверка + +- `node web/scripts/test-booking-service-safety.mjs` запускать из `web/`, + используя путь `scripts/test-booking-service-safety.mjs`. +- `go test ./internal/api -run TestAppointmentRejectsUnverifiedService` — + отказ до обращения к БД для непроверенной услуги. +- `TestDoctorServicesQuarantine` — отдельная временная БД через + `DANA_AUTONOMY_TEST_DSN`. Проверяет пустой публичный список, сохранность + сырого импорта и создание pending-записи без `service_id`. Не запускать с + DSN продакшна. +- На сайте выбрать Шугаева и свободное время: в форме контактов нет списка + услуг; не создавать проверочную запись на продакшне. + +## Откат + +Предыдущий образ: `dana-medical-api:before-booking-service-safety-20261023`. +Из каталога `/home/mikail/dana-medical`: + +```sh +docker tag dana-medical-api:before-booking-service-safety-20261023 dana-medical-api:latest +docker compose -p dana-medical -f docker-compose.prod.yml up -d --no-deps api +``` + +Проверить `/api/v1/health`. Такой откат возвращает и неверный список услуг; +использовать только при регрессии и затем согласовать исправление. БД и +медиа не откатывать, контейнер bridge не запускать. diff --git a/internal/api/onec.go b/internal/api/onec.go index e11abe7..a30100e 100644 --- a/internal/api/onec.go +++ b/internal/api/onec.go @@ -70,12 +70,10 @@ func (a *API) doctorSlots(w http.ResponseWriter, r *http.Request) { handleErr(w, err) return } - services, err := a.store.OnecServices(r.Context(), onecID) - if err != nil { - handleErr(w, err) - return - } - ok(w, map[string]any{"enabled": true, "slots": slots, "services": services}, map[string]any{"total": len(slots)}) + // The current import duplicates one mixed catalog across doctors of unrelated + // specialties. Keep it in storage for reconciliation/history, but never present + // it as a verified list of this doctor's procedures (including to old clients). + ok(w, map[string]any{"enabled": true, "slots": slots, "services": []onec.Service{}}, map[string]any{"total": len(slots)}) } type appointmentInput struct { @@ -145,6 +143,12 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз") return } + // A previously loaded form must not attach a procedure from the unverified + // catalog. Do not silently discard a choice the patient believes was recorded. + if in.ServiceID != "" { + fail(w, http.StatusUnprocessableEntity, "service_unavailable", "Список услуг обновлён. Закройте и снова откройте форму, чтобы записаться к врачу без выбора услуги.") + return + } if !a.onecAutonomousEnabled() { fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку") return diff --git a/internal/api/onec_service_safety_test.go b/internal/api/onec_service_safety_test.go new file mode 100644 index 0000000..766f637 --- /dev/null +++ b/internal/api/onec_service_safety_test.go @@ -0,0 +1,115 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "dana/internal/onec" + "dana/internal/storage" +) + +func TestAppointmentRejectsUnverifiedService(t *testing.T) { + a := &API{limiter: newRateLimiter()} + in := appointmentInput{DoctorSlug: "fictional-dentist", Start: time.Now().Add(48 * time.Hour).Format(time.RFC3339), Name: "Fictional patient", Phone: "79990000000", Consent: true, RequestID: "fictional-request", ServiceID: "unverified-service"} + body, _ := json.Marshal(in) + w := httptest.NewRecorder() + a.createAppointment(w, httptest.NewRequest(http.MethodPost, "/api/v1/appointments", bytes.NewReader(body))) + if w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), `"code":"service_unavailable"`) { + t.Fatalf("unverified service was not rejected before storage access: %d %s", w.Code, w.Body.String()) + } + in.ServiceID = "" + body, _ = json.Marshal(in) + w = httptest.NewRecorder() + a.createAppointment(w, httptest.NewRequest(http.MethodPost, "/api/v1/appointments", bytes.NewReader(body))) + if w.Code != http.StatusServiceUnavailable || !strings.Contains(w.Body.String(), `"code":"onec_disabled"`) { + t.Fatalf("booking without service must continue to normal availability checks: %d %s", w.Code, w.Body.String()) + } +} + +// Uses only a disposable database, never production or a real 1C server. +func TestDoctorServicesQuarantine(t *testing.T) { + dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN") + if dsn == "" { + t.Skip("requires disposable autonomy DB") + } + ctx := context.Background() + st, err := storage.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err = st.Migrate(ctx); err != nil { + t.Fatal(err) + } + doctor := fmt.Sprintf("service-guard-%d", time.Now().UnixNano()) + var doctorID int64 + if err = st.Pool().QueryRow(ctx, `INSERT INTO doctors(slug,last_name,first_name,booking_provider) VALUES($1,'Test','Dentist','native') RETURNING id`, doctor).Scan(&doctorID); err != nil { + t.Fatal(err) + } + if _, err = st.Pool().Exec(ctx, `INSERT INTO onec_doctors(onec_id,full_name,doctor_id) VALUES($1,'Test Dentist',$2)`, doctor, doctorID); err != nil { + t.Fatal(err) + } + if err = st.SaveOnecServices(ctx, doctor, []onec.Service{{ID: "unverified-lab", Name: "Fictional unrelated lab", DurationMin: 30, Price: 100}}); err != nil { + t.Fatal(err) + } + if err = st.SetBookingSettings(ctx, storage.BookingSettings{}); err != nil { + t.Fatal(err) + } + start := time.Now().UTC().Truncate(time.Hour).Add(72 * time.Hour) + end := start.Add(30 * time.Minute) + if err = st.AcceptOnecSlots(ctx, start.Add(-time.Hour), end.Add(time.Hour), []onec.Slot{{DoctorID: doctor, BranchID: "branch", Start: start, End: end, Status: "free"}}); err != nil { + t.Fatal(err) + } + app := &API{store: st, limiter: newRateLimiter(), onecHook: "fictional-service-guard"} + mux := app.Routes() + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/doctors/"+doctor+"/slots", nil)) + var result struct { + Data struct { + Enabled bool `json:"enabled"` + Slots []storage.OnecSlot `json:"slots"` + Services []onec.Service `json:"services"` + } `json:"data"` + } + if err = json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if w.Code != 200 || !result.Data.Enabled || len(result.Data.Slots) != 1 || result.Data.Services == nil || len(result.Data.Services) != 0 { + t.Fatalf("unverified catalog leaked or real slot disappeared: %d %s", w.Code, w.Body.String()) + } + raw, err := st.OnecServices(ctx, doctor) + if err != nil || len(raw) != 1 { + t.Fatalf("raw import must remain intact for reconciliation: %+v %v", raw, err) + } + in := appointmentInput{DoctorSlug: doctor, Start: start.Format(time.RFC3339), Name: "Fictional patient", Phone: "79990000000", Consent: true, RequestID: doctor, BranchID: "branch"} + body, _ := json.Marshal(in) + w = httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v1/appointments", bytes.NewReader(body))) + if w.Code != http.StatusCreated || !bytes.Contains(w.Body.Bytes(), []byte(`"mode":"pending"`)) { + t.Fatalf("actual slot cannot be booked without service: %d %s", w.Code, w.Body.String()) + } + pending, err := st.PendingOnecAppointments(ctx, 50) + if err != nil { + t.Fatal(err) + } + found := false + for _, record := range pending { + if record.RequestID == doctor { + found = true + if record.ServiceID != "" { + t.Fatal("unverified service was attached to pending appointment") + } + } + } + if !found { + t.Fatal("booking missing from test pending queue") + } +} diff --git a/web/scripts/test-booking-service-safety.mjs b/web/scripts/test-booking-service-safety.mjs new file mode 100644 index 0000000..cf5089d --- /dev/null +++ b/web/scripts/test-booking-service-safety.mjs @@ -0,0 +1,49 @@ +// Only fictional data and a mocked API. No live requests or appointments. +import assert from 'node:assert/strict'; +import { parseHTML } from 'linkedom'; +import { createServer } from 'vite'; + +const { window } = parseHTML('
'); +Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); +window.location = new URL('https://booking-test.invalid/'); +document.oninput = null; +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: NativeBooking } = await vite.ssrLoadModule('/src/components/NativeBooking.jsx'); +const { api } = await vite.ssrLoadModule('/src/api.js'); +const slot = { start: '2070-10-23T12:00:00+03:00', end: '2070-10-23T12:30:00+03:00', status: 'free', branch_id: 'test', branch: 'Тестовый филиал' }; +api.doctorSlots = async () => ({ data: { enabled: true, slots: [slot], services: [ + { id: 'unverified-lab', name: 'Непроверенный анализ из общего справочника' }, + { id: 'unverified-procedure', name: 'Непроверенная процедура другого направления' }, +] } }); +let submitted; +api.createAppointment = async (payload) => { submitted = payload; return { data: { message: 'Тестовая заявка принята' } }; }; +const container = document.getElementById('root'); +const root = createRoot(container); +try { + await act(async () => root.render(React.createElement(MemoryRouter, {}, React.createElement(NativeBooking, { + doctor: { slug: 'fictional-dentist' }, initialSlot: slot, + onClose() {}, onUnavailable() { throw new Error('Actual free slot must stay bookable'); }, + })))); + assert.ok(!container.querySelector('select'), 'Unverified imported services must not appear in the booking form'); + assert.doesNotMatch(container.textContent, /Непроверенный анализ|Непроверенная процедура/); + assert.match(container.textContent, /23 октября/); + await act(async () => { + Simulate.change(container.querySelector('input[type="text"]'), { target: { type: 'text', value: 'Вымышленный пациент' } }); + Simulate.change(container.querySelector('input[type="tel"]'), { target: { type: 'tel', value: '+79990000000' } }); + Simulate.change(container.querySelector('input[type="checkbox"]'), { target: { type: 'checkbox', checked: true } }); + }); + await act(async () => Simulate.submit(container.querySelector('form'))); + assert.equal(submitted.doctor_slug, 'fictional-dentist'); + assert.equal(submitted.start, slot.start); + assert.equal(submitted.branch_id, 'test'); + assert.equal(Object.hasOwn(submitted, 'service_id'), false, 'No unverified service identifier may be sent'); + assert.match(container.textContent, /Тестовая заявка принята/); + console.log('PASS: unrelated imported services hidden, real chosen slot retained, booking submitted without service_id; fictional data only'); +} finally { + await act(async () => root.unmount()); + await vite.close(); +} diff --git a/web/src/components/NativeBooking.jsx b/web/src/components/NativeBooking.jsx index d7c552c..eda03c6 100644 --- a/web/src/components/NativeBooking.jsx +++ b/web/src/components/NativeBooking.jsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import checkIcon from '../assets/figma/icon-check.svg'; -import chevronIcon from '../assets/figma/icon-accordion-chevron.svg'; import { api } from '../api.js'; import { dayLabel, groupByDay, longDate, newRequestId, slotDate, slotTime, todayKey } from '../lib/slots.js'; import { formatPhoneInput, phoneComplete } from '../lib/phone.js'; @@ -56,11 +55,9 @@ export function NextSlots({ doctor, onPick, limit = 6 }) { export default function NativeBooking({ doctor, initialSlot, filterDate = '', onClose, onUnavailable }) { const [loaded, setLoaded] = useState(false); const [slots, setSlots] = useState([]); - const [services, setServices] = useState([]); const [day, setDay] = useState(initialSlot ? slotDate(initialSlot) : ''); const [branch, setBranch] = useState(''); const [chosen, setChosen] = useState(initialSlot || null); - const [serviceId, setServiceId] = useState(''); const [values, setValues] = useState({ name: '', phone: '', comment: '', consent: false }); const [status, setStatus] = useState({ state: 'idle', message: '' }); const [notice, setNotice] = useState(''); @@ -85,7 +82,8 @@ export default function NativeBooking({ doctor, initialSlot, filterDate = '', on setSlots(actualSlots); // Окно из карточки тоже должно оставаться свободным в свежем ответе 1С. setChosen(initialSlot ? actualSlots.find((s) => s.start === initialSlot.start && s.branch_id === initialSlot.branch_id) || null : null); - setServices(data.services || []); + // Imported catalog has no verified doctor-to-service bindings. + // Book the real doctor/slot without assigning an arbitrary procedure. setLoaded(true); }) .catch((e) => { @@ -128,7 +126,6 @@ export default function NativeBooking({ doctor, initialSlot, filterDate = '', on doctor_slug: doctor.slug, start: chosen.start, branch_id: chosen.branch_id, - service_id: serviceId || undefined, name: values.name.trim(), phone: values.phone, verification_id: verificationId, @@ -369,23 +366,6 @@ export default function NativeBooking({ doctor, initialSlot, filterDate = '', on - {services.length > 1 && ( - - )} -