fix(booking): quarantine unverified shared doctor service catalog
This commit is contained in:
parent
d8e7272800
commit
7b1cfde984
46
deploy/booking-service-safety.md
Normal file
46
deploy/booking-service-safety.md
Normal 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 не запускать.
|
||||||
@ -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
|
||||||
|
|||||||
115
internal/api/onec_service_safety_test.go
Normal file
115
internal/api/onec_service_safety_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
49
web/scripts/test-booking-service-safety.mjs
Normal file
49
web/scripts/test-booking-service-safety.mjs
Normal 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();
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user