fix(booking): quarantine unverified shared doctor service catalog

This commit is contained in:
Dana Medical Developer 2026-10-23 11:58:30 +00:00
parent d8e7272800
commit 7b1cfde984
5 changed files with 222 additions and 28 deletions

View File

@ -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 не запускать.

View File

@ -70,12 +70,10 @@ func (a *API) doctorSlots(w http.ResponseWriter, r *http.Request) {
handleErr(w, err) handleErr(w, err)
return return
} }
services, err := a.store.OnecServices(r.Context(), onecID) // The current import duplicates one mixed catalog across doctors of unrelated
if err != nil { // specialties. Keep it in storage for reconciliation/history, but never present
handleErr(w, err) // it as a verified list of this doctor's procedures (including to old clients).
return ok(w, map[string]any{"enabled": true, "slots": slots, "services": []onec.Service{}}, map[string]any{"total": len(slots)})
}
ok(w, map[string]any{"enabled": true, "slots": slots, "services": services}, map[string]any{"total": len(slots)})
} }
type appointmentInput struct { type appointmentInput struct {
@ -145,6 +143,12 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз") fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз")
return 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() { if !a.onecAutonomousEnabled() {
fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку") fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку")
return return

View File

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

View File

@ -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('<html><body><div id="root"></div></body></html>');
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();
}

View File

@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import checkIcon from '../assets/figma/icon-check.svg'; import checkIcon from '../assets/figma/icon-check.svg';
import chevronIcon from '../assets/figma/icon-accordion-chevron.svg';
import { api } from '../api.js'; import { api } from '../api.js';
import { dayLabel, groupByDay, longDate, newRequestId, slotDate, slotTime, todayKey } from '../lib/slots.js'; import { dayLabel, groupByDay, longDate, newRequestId, slotDate, slotTime, todayKey } from '../lib/slots.js';
import { formatPhoneInput, phoneComplete } from '../lib/phone.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 }) { 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 [day, setDay] = useState(initialSlot ? slotDate(initialSlot) : ''); const [day, setDay] = useState(initialSlot ? slotDate(initialSlot) : '');
const [branch, setBranch] = useState(''); const [branch, setBranch] = useState('');
const [chosen, setChosen] = useState(initialSlot || null); const [chosen, setChosen] = useState(initialSlot || null);
const [serviceId, setServiceId] = useState('');
const [values, setValues] = useState({ name: '', phone: '', comment: '', consent: false }); const [values, setValues] = useState({ name: '', phone: '', comment: '', consent: false });
const [status, setStatus] = useState({ state: 'idle', message: '' }); const [status, setStatus] = useState({ state: 'idle', message: '' });
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
@ -85,7 +82,8 @@ export default function NativeBooking({ doctor, initialSlot, filterDate = '', on
setSlots(actualSlots); setSlots(actualSlots);
// Окно из карточки тоже должно оставаться свободным в свежем ответе 1С. // Окно из карточки тоже должно оставаться свободным в свежем ответе 1С.
setChosen(initialSlot ? actualSlots.find((s) => s.start === initialSlot.start && s.branch_id === initialSlot.branch_id) || null : null); 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); setLoaded(true);
}) })
.catch((e) => { .catch((e) => {
@ -128,7 +126,6 @@ export default function NativeBooking({ doctor, initialSlot, filterDate = '', on
doctor_slug: doctor.slug, doctor_slug: doctor.slug,
start: chosen.start, start: chosen.start,
branch_id: chosen.branch_id, branch_id: chosen.branch_id,
service_id: serviceId || undefined,
name: values.name.trim(), name: values.name.trim(),
phone: values.phone, phone: values.phone,
verification_id: verificationId, verification_id: verificationId,
@ -369,23 +366,6 @@ export default function NativeBooking({ doctor, initialSlot, filterDate = '', on
</label> </label>
</div> </div>
{services.length > 1 && (
<label className="lead-field">
<span className="lead-field__label">Услуга</span>
<span className="lead-field__select">
<select className="lead-field__control" value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
<option value="">Выберите, если знаете</option>
{services.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<img src={chevronIcon} alt="" width="16" height="16" />
</span>
</label>
)}
<label className="lead-consent"> <label className="lead-consent">
<input type="checkbox" checked={values.consent} onChange={set('consent')} /> <input type="checkbox" checked={values.consent} onChange={set('consent')} />
<span className="lead-consent__box" aria-hidden="true"> <span className="lead-consent__box" aria-hidden="true">