Compare commits

..

No commits in common. "7ac18ec61db0bc82d43e91e8a50f3106562994f7" and "33b0b892711c5bf4b055810b7f9135ff92403017" have entirely different histories.

22 changed files with 958 additions and 1285 deletions

View File

@ -8,7 +8,7 @@
- **Frontend (`web/`):** React 18, Vite, Tailwind CSS, SSR рендеринг (`scripts/render-page.mjs`). - **Frontend (`web/`):** React 18, Vite, Tailwind CSS, SSR рендеринг (`scripts/render-page.mjs`).
- **Backend API (`cmd/server/`, `internal/`):** Go 1.22+ сервис обработки заявок, онлайн-записи, каталога услуг, врачей и чек-апов. - **Backend API (`cmd/server/`, `internal/`):** Go 1.22+ сервис обработки заявок, онлайн-записи, каталога услуг, врачей и чек-апов.
- **Интеграция 1C (`internal/api/onec*.go`, `internal/storage/onec*.go`):** автономный Push/Pull. 1С сама отправляет снимки и хуки, забирает pending и команды. `bridge/` сохранен как архив исходников; рабочий Compose его не запускает. - **Интеграция 1C (`bridge/bridge_1c.py`):** Python-мост для синхронизации слотов расписания, врачей, номенклатуры и создания заявок в 1С:Медицина.
- **База данных:** PostgreSQL 16 (данные сохраняются в Docker volume `dana-medical_db-data`). - **База данных:** PostgreSQL 16 (данные сохраняются в Docker volume `dana-medical_db-data`).
- **Медиафайлы:** сохраняются в volume `dana-medical_media` (монтируется в `/data/media`). - **Медиафайлы:** сохраняются в volume `dana-medical_media` (монтируется в `/data/media`).
@ -48,7 +48,7 @@
/home/mikail/dana-medical/deploy.sh /home/mikail/dana-medical/deploy.sh
``` ```
Скрипт автоматически: Скрипт автоматически:
1. Затягивает свежие изменения из ветки `main` (`git fetch origin main && git merge --ff-only origin/main`). 1. Затягивает свежие изменения из ветки `main` (`git fetch origin main && git reset --hard origin/main`).
2. Собирает Docker-образ бэкенда и веб-части (`docker compose -p dana-medical -f docker-compose.prod.yml build api`). 2. Собирает Docker-образ бэкенда и веб-части (`docker compose -p dana-medical -f docker-compose.prod.yml build api`).
3. Бесшовно перезапускает контейнер API (`up -d api`). 3. Бесшовно перезапускает контейнер API (`up -d api`).
4. Проверяет `healthcheck` эндпоинта `/api/v1/health` и подтверждает успешность выкладки. 4. Проверяет `healthcheck` эндпоинта `/api/v1/health` и подтверждает успешность выкладки.

View File

@ -80,8 +80,10 @@ func run() error {
handler.WithClinicPBX("ochevidno", megafon.New(cfg.OchevidnoMegafonURL, cfg.OchevidnoMegafonKey)) handler.WithClinicPBX("ochevidno", megafon.New(cfg.OchevidnoMegafonURL, cfg.OchevidnoMegafonKey))
} }
go handler.RunPBXSync(ctx) go handler.RunPBXSync(ctx)
handler.WithOneC(onec.New("", cfg.OnecKey, cfg.OnecAuthHeader)) handler.WithOneC(onec.New(cfg.OnecURL, cfg.OnecKey, cfg.OnecAuthHeader))
handler.WithOneCWebhook(cfg.OnecWebhookKey) handler.WithOneCWebhook(cfg.OnecWebhookKey)
handler.WithOneCTuning(time.Duration(cfg.OnecSyncSecs)*time.Second, cfg.OnecLongPoll)
go handler.RunOneCSync(ctx)
handler.WithBitrix(bitrix.New(cfg.BitrixWebhookURL)) handler.WithBitrix(bitrix.New(cfg.BitrixWebhookURL))
handler.WithCRM(crm.New(cfg.CrmAPIURL, cfg.CrmLeadIntakeSecret), cfg.CrmMode) handler.WithCRM(crm.New(cfg.CrmAPIURL, cfg.CrmLeadIntakeSecret), cfg.CrmMode)
go handler.RunBookingConfirm(ctx) go handler.RunBookingConfirm(ctx)

View File

@ -9,28 +9,22 @@ cd "$DEPLOY_DIR"
# 1. Pull latest code from Gitea # 1. Pull latest code from Gitea
echo "Pulling latest changes from origin/main..." echo "Pulling latest changes from origin/main..."
if [ -n "$(git status --porcelain)" ]; then
echo "Deployment refused: working tree contains changes" >&2
exit 1
fi
git fetch origin main git fetch origin main
git merge --ff-only origin/main git reset --hard origin/main
# 2. Build and restart API container # 2. Build and restart API container
echo "Building and restarting api service..." echo "Building and restarting api service..."
docker compose -p dana-medical -f docker-compose.prod.yml build api docker compose -p dana-medical -f docker-compose.prod.yml build api
docker compose -p dana-medical -f docker-compose.prod.yml up -d --no-deps api docker compose -p dana-medical -f docker-compose.prod.yml up -d api
# 3. Check health status # 3. Check health status
echo "Verifying health..." echo "Verifying health..."
api_id="$(docker compose -p dana-medical -f docker-compose.prod.yml ps -q api)" api_id="$(docker compose -p dana-medical -f docker-compose.prod.yml ps -q api)"
test -n "$api_id" test -n "$api_id"
healthy=false
for i in $(seq 1 30); do for i in $(seq 1 30); do
status="$(docker inspect --format '{{.State.Health.Status}}' "$api_id" 2>/dev/null || echo "starting")" status="$(docker inspect --format '{{.State.Health.Status}}' "$api_id" 2>/dev/null || echo "starting")"
if [ "$status" = "healthy" ]; then if [ "$status" = "healthy" ]; then
healthy=true
echo "API service is healthy!" echo "API service is healthy!"
break break
fi fi
@ -42,16 +36,4 @@ for i in $(seq 1 30); do
sleep 2 sleep 2
done done
if [ "$healthy" != true ]; then
echo "API health check timed out" >&2
exit 1
fi
# Outbound-only 1C polling supersedes the legacy bridge. Retain the stopped
# container for rollback, but do not let it return on a Docker daemon restart.
if docker inspect dana-1c-bridge >/dev/null 2>&1; then
docker update --restart=no dana-1c-bridge >/dev/null
docker stop --time 15 dana-1c-bridge >/dev/null
fi
echo "[$(date --iso-8601=seconds)] Deployment completed successfully!" echo "[$(date --iso-8601=seconds)] Deployment completed successfully!"

View File

@ -29,9 +29,6 @@ services:
NODE_BINARY: node NODE_BINARY: node
SITE_URL: ${SITE_URL:?SITE_URL is required} SITE_URL: ${SITE_URL:?SITE_URL is required}
DEBUG: "false" DEBUG: "false"
ONEC_API_URL: ""
ONEC_MCP_URL: ""
ONEC_MCP_TOKEN: ""
# Caddy уже работает в контейнере Ability Center. Публикуем DANA только # Caddy уже работает в контейнере Ability Center. Публикуем DANA только
# на docker0 хоста: из интернета порт недоступен, Caddy обращается к нему # на docker0 хоста: из интернета порт недоступен, Caddy обращается к нему
# как к 172.17.0.1:8082. # как к 172.17.0.1:8082.
@ -51,6 +48,46 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
# Мост к 1C MCP. Единственный контейнер, которому разрешён egress на
# 10.240.70.70:6003 (правило DOCKER-USER, см. deploy/firewall-1c-egress.sh).
# IP закреплён: правило фаервола привязано к адресу, а не к имени.
bridge:
build:
context: ./bridge
image: dana-1c-bridge:latest
container_name: dana-1c-bridge
restart: unless-stopped
env_file:
- .env
environment:
ONEC_MCP_URL: ${ONEC_MCP_URL:-http://10.240.70.70:6003/api/execute_code}
ONEC_MCP_TOKEN: ${ONEC_MCP_TOKEN:?ONEC_MCP_TOKEN is required}
# Go-бэкенд авторизуется перед мостом тем же ключом, что шлёт в ONEC_API_URL.
BRIDGE_API_KEY: ${ONEC_API_KEY:?ONEC_API_KEY is required}
PG_HOST: db
PG_PORT: "5432"
PG_USER: dana
PG_DB: dana
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
depends_on:
db:
condition: service_healthy
networks:
default:
ipv4_address: 172.20.0.50
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8088/healthz', timeout=3)"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:size=16m
volumes: volumes:
db-data: db-data:

View File

@ -1,35 +0,0 @@
# Автономная интеграция 1С
1С сама вызывает HTTPS API сайта; API не вызывает bridge или MCP. Запуск `RunOneCSync` удален, обратные сетевые вызовы создания, привязки и отмены из API удалены. Настройка `ONEC_WEBHOOK_KEY` включает автономный режим; Compose очищает старые `ONEC_API_URL`, `ONEC_MCP_URL`, `ONEC_MCP_TOKEN` в контейнере.
## Контракт
- `POST /api/v1/onec/sync/all`: полный снимок `branches`, `doctors`, `services`, `slots`, диапазон `from/to`. Пустое расписание очищает горизонт; успешный прием обновляет свежесть Push. При дубликатах слота `busy` имеет приоритет. Для совместимости снимок старого отправителя без границ принимает диапазон входящих слотов.
- `GET /api/v1/onec/appointments/pending`: `data.appointments`, `data.commands`, `data.sync_requested`. Начало передается со смещением Москвы, включая комментарий и дату рождения, если они были указаны пользователем.
- `POST /api/v1/onec/appointments/{id}/confirm`: UUID документа и окончание приема. Повтор с тем же UUID безопасен, несовместимое подтверждение/отказ после создания дает 409.
- `POST /api/v1/onec/appointments/{id}/reject`: причина отказа, перевод в заявку оператору, если настроен CRM-контур.
- `POST /api/v1/onec/events`: события статуса и запроса нового Push. Обратных HTTP-вызовов нет; устаревший `booked` не возвращает отмененную заявку. Изменение времени переносит интервал локальной занятости.
- `POST /api/v1/onec/commands/{id}/ack`: `{success:true}` либо `{success:false,error:"..."}`. Неуспешная команда остается доступной. Типы команд: `link_bitrix`, `link_crm`, `cancel`. Отмена остается `cancel_pending` и занимает интервал до ACK 1С. После ACK просроченной записи отдельный повторяемый шаг закрывает лид Б24.
Все методы защищены ключом 1С. Привязка Б24 сохраняется долговечно и повторяется, даже если первый обмен после получения ID не удался. Создание самого лида Б24 остается существующим REST-клиентом; отдельная сделка не гарантируется одним наличием лида.
## Занятость и повторы
`BeginOnecAppointment` блокирует ключ запроса и врача через PostgreSQL advisory locks; выбор свободного окна и вставка pending выполняются в одной транзакции. Публичные свободные окна исключают пересечения с pending/booked/arrived по врачу, включая другие филиалы. Это правило действует и после следующего снимка, содержащего free. Заявки, отсеянные защитой от ботов, сразу сохраняются как failed для оператора и никогда не попадают в Pull-создание.
Миграция `0027_onec_autonomous.sql` добавляет комментарий/дату рождения в очередь, таблицы состояния Push и команд. Она не удаляет клинические данные и совместима со старым API при возврате образа. Перед применением нужен серверный pg_dump и сохраненный предыдущий образ.
## Приемка
Проверки запускаются на Linux в отдельной временной PostgreSQL:
```sh
DANA_AUTONOMY_TEST_DSN=postgres://postgres:TEST_PASSWORD@127.0.0.1:5432/dana_autonomy_test?sslmode=disable go test -p 1 -count=1 ./...
go vet ./...
```
DSN должен указывать исключительно на временную тестовую базу. В сценариях проверяются публичный POST → pending, скрытие зарезервированного времени после Push, 12 конкурирующих запросов, повторный ключ, callbacks/409, запрос снимка, долговечные команды, ACK/повтор ACK и очистка пустого горизонта. Тесты не доказывают исполнение BSL на платформе 1С.
Обновление 1С: пять общих модулей `ДМ_СайтAPI`, `ДМ_СайтРасписание`, `ДМ_СайтЗапись`, `ДМ_СайтХуки`, `ДМ_СайтПодписки` из пакета в проекте config_dump. Нужны проверка на Test, обновление UMC и новый сквозной тест после загрузки. Резервный входящий HTTP-сервис 1С не нужен этому маршруту.
После проверки здоровья API `deploy.sh` останавливает только контейнер dana-1c-bridge и снимает его автозапуск. Сохраняются DB/media и общий Caddy. Откат: предыдущий образ и прежний Compose из серверного каталога rollback; bridge запускается вручную только если возвращен прежний режим.

View File

@ -239,7 +239,7 @@ const Onec = {
} }
const STATUS = { booked: ['', 'Записан'], pending: ['wait', 'Отправляется'], failed: ['off', 'Не записан'], cancelled: ['grey', 'Отменён'], arrived: ['', 'Пришёл'] }; const STATUS = { booked: ['', 'Записан'], pending: ['wait', 'Отправляется'], failed: ['off', 'Не записан'], cancelled: ['grey', 'Отменён'], arrived: ['', 'Пришёл'] };
const CONFIRM = { const CONFIRM = {
waiting: ['wait', 'Ждёт оператора'], cancel_pending: ['wait', 'Ожидает отмены в 1С'], confirmed: ['', 'Подтверждена'], cancelled: ['grey', 'Отменена оператором'], waiting: ['wait', 'Ждёт оператора'], confirmed: ['', 'Подтверждена'], cancelled: ['grey', 'Отменена оператором'],
expired: ['off', 'Не подтвердили — отменена'], missed: ['grey', 'Приём без подтверждения'], expired: ['off', 'Не подтвердили — отменена'], missed: ['grey', 'Приём без подтверждения'],
lead_only: ['wait', 'Отправляется в Битрикс'], lead_sent: ['', 'Заявка в Битриксе'], lead_only: ['wait', 'Отправляется в Битрикс'], lead_sent: ['', 'Заявка в Битриксе'],
}; };

View File

@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"reflect" "reflect"
"strings" "strings"
"time"
"dana/internal/bitrix" "dana/internal/bitrix"
"dana/internal/crm" "dana/internal/crm"
@ -27,6 +28,8 @@ type API struct {
pbxClinics map[string]*megafon.Client // АТС других клиник (clinics.go) pbxClinics map[string]*megafon.Client // АТС других клиник (clinics.go)
onec *onec.Client onec *onec.Client
onecHook string onecHook string
onecEvery time.Duration
onecLongPoll bool
bitrix *bitrix.Client bitrix *bitrix.Client
crm *crm.Client crm *crm.Client
crmMode string crmMode string
@ -138,7 +141,6 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/onec/appointments/pending", a.onecPendingAppointments) mux.HandleFunc("GET /api/v1/onec/appointments/pending", a.onecPendingAppointments)
mux.HandleFunc("POST /api/v1/onec/appointments/{id}/confirm", a.onecConfirmAppointment) mux.HandleFunc("POST /api/v1/onec/appointments/{id}/confirm", a.onecConfirmAppointment)
mux.HandleFunc("POST /api/v1/onec/appointments/{id}/reject", a.onecRejectAppointment) mux.HandleFunc("POST /api/v1/onec/appointments/{id}/reject", a.onecRejectAppointment)
mux.HandleFunc("POST /api/v1/onec/commands/{id}/ack", a.onecCommandAck)
mux.HandleFunc("GET /api/v1/admin/onec", a.requireAuth(a.adminOnec)) mux.HandleFunc("GET /api/v1/admin/onec", a.requireAuth(a.adminOnec))
mux.HandleFunc("POST /api/v1/admin/onec/sync", a.requireAuth(a.adminOnecSync)) mux.HandleFunc("POST /api/v1/admin/onec/sync", a.requireAuth(a.adminOnecSync))
mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/link", a.requireAuth(a.adminOnecLink)) mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/link", a.requireAuth(a.adminOnecLink))

View File

@ -13,6 +13,7 @@ import (
"dana/internal/bitrix" "dana/internal/bitrix"
"dana/internal/crm" "dana/internal/crm"
"dana/internal/onec"
"dana/internal/storage" "dana/internal/storage"
) )
@ -82,7 +83,7 @@ func (a *API) bookingRiskReason(ctx context.Context, cfg storage.BookingSettings
} }
func (a *API) bookingConfirmEnabled() bool { func (a *API) bookingConfirmEnabled() bool {
return a.onecAutonomousEnabled() && (a.bitrixEnabled() || a.crmEnabled()) return a.onec != nil && a.onec.Configured() && (a.bitrixEnabled() || a.crmEnabled())
} }
// kickConfirm будит фоновую проверку сразу после новой записи, чтобы лид появился без ожидания минуты. // kickConfirm будит фоновую проверку сразу после новой записи, чтобы лид появился без ожидания минуты.
@ -156,22 +157,6 @@ func (a *API) RunBookingConfirm(ctx context.Context) {
} }
func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error { func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error {
if a.bitrixEnabled() {
expired, err := a.store.ExpiredOnecLeads(ctx)
if err != nil {
return err
}
for _, v := range expired {
if err = a.bitrix.FailLead(ctx, v.LeadID); err != nil {
slog.Warn("expire Bitrix lead after 1C ACK", "lead", v.LeadID, "err", err)
continue
}
if err = a.store.MarkExpiredOnecLead(ctx, v.CommandID); err != nil {
return err
}
}
}
if err := a.sendLeadOnly(ctx); err != nil { if err := a.sendLeadOnly(ctx); err != nil {
return err return err
} }
@ -203,6 +188,13 @@ func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error {
it.BitrixLeadID = id it.BitrixLeadID = id
slog.Info("booking confirm: лид создан в Битрикс", "appointment", it.ID, "lead", id) slog.Info("booking confirm: лид создан в Битрикс", "appointment", it.ID, "lead", id)
if it.OnecID != "" {
if err := a.onec.LinkBitrix(ctx, it.OnecID, id); err != nil {
slog.Warn("booking confirm: не удалось связать 1С с Битриксом", "onec_id", it.OnecID, "lead", id, "err", err)
} else {
slog.Info("booking confirm: заявка в 1С связана с лидом Битрикса", "onec_id", it.OnecID, "lead", id)
}
}
} }
} }
@ -219,27 +211,15 @@ func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error {
slog.Info("booking confirm: сделка создана в CRM", "appointment", it.ID, "deal", dealID) slog.Info("booking confirm: сделка создана в CRM", "appointment", it.ID, "deal", dealID)
if it.OnecID != "" { if it.OnecID != "" {
if err := a.store.QueueOnecCommand(ctx, it.ID, "link_crm", dealID, ""); err != nil { if err := a.onec.LinkCRM(ctx, it.OnecID, dealID); err != nil {
slog.Warn("booking confirm: не удалось связать 1С со сделкой CRM", "onec_id", it.OnecID, "deal", dealID, "err", err) slog.Warn("booking confirm: не удалось связать 1С со сделкой CRM", "onec_id", it.OnecID, "deal", dealID, "err", err)
} else { } else {
slog.Info("booking confirm: связь 1С с CRM поставлена в очередь", "onec_id", it.OnecID, "deal", dealID) slog.Info("booking confirm: заявка в 1С связана со сделкой CRM", "onec_id", it.OnecID, "deal", dealID)
} }
} }
} }
} }
if it.OnecID != "" {
if it.BitrixLeadID != "" {
if err := a.store.QueueOnecCommand(ctx, it.ID, "link_bitrix", it.BitrixLeadID, ""); err != nil {
return err
}
}
if it.CrmDealID != "" {
if err := a.store.QueueOnecCommand(ctx, it.ID, "link_crm", it.CrmDealID, ""); err != nil {
return err
}
}
}
if it.BitrixLeadID != "" { if it.BitrixLeadID != "" {
bitrixIds = append(bitrixIds, it.BitrixLeadID) bitrixIds = append(bitrixIds, it.BitrixLeadID)
} }
@ -261,16 +241,28 @@ func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error {
case confirmMissed: case confirmMissed:
a.finishConfirm(ctx, it, "missed") a.finishConfirm(ctx, it, "missed")
case confirmCancel: case confirmCancel:
a.queueAutonomousCancel(ctx, it, "cancelled") if a.cancelUnconfirmed(ctx, it) {
a.finishConfirm(ctx, it, "cancelled")
a.leadNote(ctx, it, "Сайт отменил запись в 1С: лид переведён в провальную стадию. Окно у врача снова свободно.")
}
case confirmExpire: case confirmExpire:
a.queueAutonomousCancel(ctx, it, "expired") if a.cancelUnconfirmed(ctx, it) {
a.finishConfirm(ctx, it, "expired")
if err := a.bitrix.FailLead(ctx, it.BitrixLeadID); err != nil {
slog.Warn("booking confirm: стадия лида не обновлена", "lead", it.BitrixLeadID, "err", err)
}
a.leadNote(ctx, it, "Запись не подтвердили вовремя — сайт отменил её в 1С и перевёл лид в «Клиент не записался». "+
"Если пациент всё же придёт, запишите его заново.")
}
} }
} }
} else if !a.bitrixEnabled() && a.crmEnabled() { } else if !a.bitrixEnabled() && a.crmEnabled() {
// В режиме CRM-only проверяем автоотмену по истечении срока подтверждения // В режиме CRM-only проверяем автоотмену по истечении срока подтверждения
for _, it := range items { for _, it := range items {
if d, ok := confirmDeadline(it.CreatedAt, it.Start, cfg); ok && !now.Before(d) { if d, ok := confirmDeadline(it.CreatedAt, it.Start, cfg); ok && !now.Before(d) {
a.queueAutonomousCancel(ctx, it, "expired") if a.cancelUnconfirmed(ctx, it) {
a.finishConfirm(ctx, it, "expired")
}
} }
} }
} }
@ -333,10 +325,26 @@ func (a *API) finishConfirm(ctx context.Context, it storage.ConfirmItem, state s
slog.Info("booking confirm", "appointment", it.ID, "lead", it.BitrixLeadID, "state", state) slog.Info("booking confirm", "appointment", it.ID, "lead", it.BitrixLeadID, "state", state)
} }
// Cancellation remains occupied until the polling 1C session acknowledges its durable command. // cancelUnconfirmed отменяет запись в 1С. false — 1С не ответила, попробуем на следующем круге.
func (a *API) queueAutonomousCancel(ctx context.Context, it storage.ConfirmItem, finalState string) { func (a *API) cancelUnconfirmed(ctx context.Context, it storage.ConfirmItem) bool {
if err := a.store.QueueOnecCommand(ctx, it.ID, "cancel", "", finalState); err != nil { if it.OnecID != "" {
slog.Warn("queue autonomous cancellation", "appointment", it.ID, "err", err) err := a.onec.Cancel(ctx, it.OnecID)
// 404 1С возвращает как «не найден»: записи там уже нет — значит, и отменять нечего
if err != nil && !errors.Is(err, onec.ErrDoctorNotFound) {
slog.Warn("booking confirm: 1С не отменила запись", "appointment", it.ID, "err", err)
_ = a.store.SetConfirmError(ctx, it.ID, "1С: "+onecErrText(err))
return false
}
}
if err := a.resyncOnecDoctor(ctx, it.DoctorOnec); err != nil {
slog.Warn("booking confirm: окна врача не обновились", "doctor", it.DoctorOnec, "err", err)
}
return true
}
func (a *API) leadNote(ctx context.Context, it storage.ConfirmItem, text string) {
if err := a.bitrix.AddLeadComment(ctx, it.BitrixLeadID, text); err != nil {
slog.Warn("booking confirm: комментарий в лид не добавлен", "lead", it.BitrixLeadID, "err", err)
} }
} }
@ -354,9 +362,6 @@ func leadDraft(it storage.ConfirmItem, cfg storage.BookingSettings) bitrix.LeadD
"Приём: " + formatSlotTime(it.Start), "Приём: " + formatSlotTime(it.Start),
"Филиал: " + html.EscapeString(branchOrDash(it.Branch)), "Филиал: " + html.EscapeString(branchOrDash(it.Branch)),
} }
if it.Comment != "" {
lines = append(lines, "Комментарий пациента: "+html.EscapeString(it.Comment))
}
if it.ServiceName != "" { if it.ServiceName != "" {
lines = append(lines, "Услуга: "+html.EscapeString(it.ServiceName)) lines = append(lines, "Услуга: "+html.EscapeString(it.ServiceName))
} }

View File

@ -10,6 +10,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"unicode/utf8" "unicode/utf8"
@ -18,23 +19,98 @@ import (
"dana/internal/storage" "dana/internal/storage"
) )
// 1С сама отправляет расписание и забирает pending. Сайт не вызывает 1С по сети. // Расписание врачей и запись на приём из 1С: окна забираются по таймеру и хранятся у нас,
// перед записью окно перепроверяется в 1С. Если 1С не отвечает, сайт не падает, а принимает обычную заявку.
const ( const (
onecHorizonDays = 14 onecHorizonDays = 14
onecSyncEvery = 5 * time.Minute
onecDirEvery = time.Hour
onecStaleAfter = 24 * time.Hour // после этого срока без удачной синхронизации окна не показываем onecStaleAfter = 24 * time.Hour // после этого срока без удачной синхронизации окна не показываем
onecSourceLabel = "Сайт"
maxAppointmentIn = 16 << 10 maxAppointmentIn = 16 << 10
) )
// WithOneC подключает 1С; без адреса или ключа запись через 1С выключена. // WithOneC подключает 1С; без адреса или ключа запись через 1С выключена.
func (a *API) WithOneC(c *onec.Client) { a.onec = c } func (a *API) WithOneC(c *onec.Client) { a.onec = c }
// WithOneCTuning задаёт период опроса (0 — по умолчанию) и включает long polling через GET /changes.
func (a *API) WithOneCTuning(every time.Duration, longPoll bool) {
a.onecEvery, a.onecLongPoll = every, longPoll
}
var onecSyncMu sync.Mutex // одна синхронизация за раз: фоновая и ручная не пересекаются
func onecWindow() (from, to time.Time) { func onecWindow() (from, to time.Time) {
now := time.Now().In(moscow) now := time.Now().In(moscow)
from = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, moscow) from = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, moscow)
return from, from.AddDate(0, 0, onecHorizonDays) return from, from.AddDate(0, 0, onecHorizonDays)
} }
// syncOneC забирает расписание на 14 дней; справочники (филиалы, врачи, услуги) — при dir=true.
func (a *API) syncOneC(ctx context.Context, dir bool) (err error) {
if !a.onec.Configured() {
return errors.New("1С не подключена: задайте ONEC_API_URL и ONEC_API_KEY")
}
onecSyncMu.Lock()
defer onecSyncMu.Unlock()
logID, err := a.store.StartOnecSync(ctx)
if err != nil {
return err
}
doctors, slotsN := 0, 0
defer func() {
errText := ""
if err != nil {
errText = onecErrText(err)
}
if e := a.store.FinishOnecSync(context.Background(), logID, doctors, slotsN, errText); e != nil {
slog.Warn("onec sync log", "err", e)
}
}()
if dir {
branches, err := a.onec.Branches(ctx)
if err != nil {
return fmt.Errorf("филиалы: %w", err)
}
docs, err := a.onec.Doctors(ctx)
if err != nil {
return fmt.Errorf("врачи: %w", err)
}
linked, err := a.store.SaveOnecDirectory(ctx, branches, docs)
if err != nil {
return err
}
doctors = len(docs)
if linked > 0 {
slog.Info("onec: врачи привязаны по ФИО", "count", linked)
}
ids, err := a.store.LinkedOnecDoctorIDs(ctx)
if err != nil {
return err
}
for _, id := range ids {
svc, err := a.onec.Services(ctx, id)
if err != nil {
slog.Warn("onec: услуги врача", "doctor", id, "err", err)
continue
}
if err := a.store.SaveOnecServices(ctx, id, svc); err != nil {
return err
}
}
}
from, to := onecWindow()
slots, err := a.onec.Schedule(ctx, from, to, "")
if err != nil {
return fmt.Errorf("расписание: %w", err)
}
slotsN = len(slots)
return a.store.ReplaceOnecSlots(ctx, from, to, slots)
}
func onecErrText(err error) string { func onecErrText(err error) string {
if errors.Is(err, onec.ErrUnauthorized) { if errors.Is(err, onec.ErrUnauthorized) {
return "1С не приняла ключ доступа: проверьте ONEC_API_KEY и что IP сервера сайта открыт в 1С. " + err.Error() return "1С не приняла ключ доступа: проверьте ONEC_API_KEY и что IP сервера сайта открыт в 1С. " + err.Error()
@ -42,6 +118,45 @@ func onecErrText(err error) string {
return err.Error() return err.Error()
} }
// RunOneCSync — фоновая синхронизация: при старте и каждые 5 минут; справочники — раз в час.
func (a *API) RunOneCSync(ctx context.Context) {
if !a.onec.Configured() {
return
}
slog.Info("onec sync enabled", "host", a.onec.Host())
lastDir := time.Time{}
run := func() {
dir := time.Since(lastDir) >= onecDirEvery
c, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := a.syncOneC(c, dir); err != nil {
slog.Warn("onec sync", "err", err)
return
}
if dir {
lastDir = time.Now()
}
}
run()
every := a.onecEvery
if every <= 0 {
every = onecSyncEvery
}
if a.onecLongPoll {
go a.runOneCLongPoll(ctx)
}
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
run()
}
}
}
// ---------- Публичное API ---------- // ---------- Публичное API ----------
// doctorSlots отдаёт свободные окна врача и услуги приёма. enabled=false — запись через 1С недоступна, // doctorSlots отдаёт свободные окна врача и услуги приёма. enabled=false — запись через 1С недоступна,
@ -49,7 +164,7 @@ func onecErrText(err error) string {
func (a *API) doctorSlots(w http.ResponseWriter, r *http.Request) { func (a *API) doctorSlots(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
off := func() { ok(w, map[string]any{"enabled": false, "slots": []any{}, "services": []any{}}, nil) } off := func() { ok(w, map[string]any{"enabled": false, "slots": []any{}, "services": []any{}}, nil) }
if !a.onecAutonomousEnabled() { if !a.onec.Configured() {
off() off()
return return
} }
@ -143,15 +258,11 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз") fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз")
return return
} }
if !a.onecAutonomousEnabled() { if !a.onec.Configured() {
fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку") fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку")
return return
} }
ctx := r.Context() ctx := r.Context()
if !a.store.OnecFresh(ctx, onecStaleAfter) {
fail(w, 503, "onec_stale", "Расписание временно недоступно")
return
}
onecID, doctorID, err := a.store.OnecDoctorBySlug(ctx, in.DoctorSlug) onecID, doctorID, err := a.store.OnecDoctorBySlug(ctx, in.DoctorSlug)
if err != nil { if err != nil {
handleErr(w, err) handleErr(w, err)
@ -195,18 +306,10 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
return return
} }
initialStatus := "pending"
if leadOnly != "" {
initialStatus = "failed"
}
rec, created, err := a.store.BeginOnecAppointment(ctx, storage.OnecAppointment{ rec, created, err := a.store.BeginOnecAppointment(ctx, storage.OnecAppointment{
RequestID: in.RequestID, DoctorOnec: onecID, BranchID: in.BranchID, ServiceID: in.ServiceID, RequestID: in.RequestID, DoctorOnec: onecID, BranchID: in.BranchID, ServiceID: in.ServiceID,
Start: start, Patient: in.Name, Phone: in.Phone, Comment: in.Comment, BirthDate: in.BirthDate, Status: initialStatus, Start: start, Patient: in.Name, Phone: in.Phone,
}) })
if errors.Is(err, storage.ErrOnecSlotBusy) {
fail(w, http.StatusConflict, "slot_busy", "Окно уже занято; выберите другое время")
return
}
if err != nil { if err != nil {
handleErr(w, err) handleErr(w, err)
return return
@ -222,21 +325,108 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
return return
} }
branch := a.store.OnecBranchName(ctx, rec.BranchID) var slot *onec.Slot
leadID := a.saveAppointmentLead(ctx, r, in, doctorID, fmt.Sprintf("Ожидает регистрации в 1С: %s, %s.%s", var end time.Time
formatSlotTime(rec.Start), branchOrDash(branch), a.serviceNote(ctx, onecID, in.ServiceID)), map[string]any{ var apptID string
"onec_doctor_id": onecID, "slot_start": rec.Start.Format(time.RFC3339),
if a.onec.Configured() {
// Окно ещё свободно? Спрашиваем 1С по этому врачу на этот день.
dayFrom := time.Date(start.In(moscow).Year(), start.In(moscow).Month(), start.In(moscow).Day(), 0, 0, 0, 0, moscow)
dayTo := dayFrom.AddDate(0, 0, 1)
fresh, err := a.onec.Schedule(ctx, dayFrom, dayTo, onecID)
if err != nil {
a.appointmentFallback(w, r, in, doctorID, rec, err)
return
}
_ = a.store.ReplaceOnecDoctorSlots(ctx, onecID, dayFrom, dayTo, fresh)
for i := range fresh {
s := fresh[i]
if s.Free() && s.Start.Equal(start) && (in.BranchID == "" || s.BranchID == in.BranchID) {
slot = &s
break
}
}
if slot == nil {
a.slotTaken(w, r, rec, onecID, "окно уже занято")
return
}
appt, err := a.onec.Book(ctx, onec.AppointmentRequest{
RequestID: in.RequestID, DoctorID: onecID, BranchID: slot.BranchID, ServiceID: in.ServiceID, SlotID: slot.SlotID,
Start: slot.Start, Name: in.Name, Phone: in.Phone, BirthDate: in.BirthDate, Comment: in.Comment, Source: onecSourceLabel,
}) })
if err := a.store.AttachOnecLead(ctx, rec.ID, leadID); err != nil { switch {
slog.Error("attach pending appointment lead", "id", rec.ID, "err", err) case errors.Is(err, onec.ErrSlotBusy):
_ = a.store.MarkOnecSlotBusy(ctx, onecID, slot.BranchID, slot.Start)
a.slotTaken(w, r, rec, onecID, err.Error())
return
case err != nil:
a.appointmentFallback(w, r, in, doctorID, rec, err)
return
}
_ = a.store.MarkOnecSlotBusy(ctx, onecID, slot.BranchID, slot.Start)
end = appt.End
if end.IsZero() {
end = slot.End
}
apptID = appt.ID
} else {
// Режим Push/Pull (1С сама опрашивает очередь по регламентному заданию)
localSlots, err := a.store.FreeOnecSlots(ctx, onecID, start, start.Add(time.Minute))
if err != nil || len(localSlots) == 0 {
a.slotTaken(w, r, rec, onecID, "окно уже занято")
return
}
for i := range localSlots {
ls := localSlots[i]
if in.BranchID == "" || ls.BranchID == in.BranchID {
slot = &onec.Slot{
DoctorID: onecID,
BranchID: ls.BranchID,
Start: ls.Start,
End: ls.End,
Status: "free",
}
break
}
}
if slot == nil {
a.slotTaken(w, r, rec, onecID, "окно уже занято")
return
}
_ = a.store.MarkOnecSlotBusy(ctx, onecID, slot.BranchID, slot.Start)
end = slot.End
}
branch := a.store.OnecBranchName(ctx, slot.BranchID)
confirm := a.bookingConfirmEnabled()
note := "Запись подтверждена в 1С"
if confirm {
note = "Запись сделана в 1С, ждёт подтверждения оператором в Битрикс24"
}
leadID := a.saveAppointmentLead(ctx, r, in, doctorID, fmt.Sprintf("%s: %s, %s.%s",
note, formatSlotTime(slot.Start), branchOrDash(branch), a.serviceNote(ctx, onecID, in.ServiceID)), map[string]any{
"onec_appointment_id": apptID, "onec_doctor_id": onecID, "slot_start": slot.Start.Format(time.RFC3339),
})
if apptID != "" {
if err := a.store.FinishOnecAppointment(context.Background(), rec.ID, "booked", apptID, "", &end, leadID); err != nil {
slog.Error("onec appointment finish", "id", rec.ID, "err", err)
}
if confirm {
if err := a.store.StartBookingConfirm(context.Background(), rec.ID); err != nil {
slog.Error("booking confirm start", "id", rec.ID, "err", err)
}
a.kickConfirm()
}
} }
wcm.consume(in.VerificationID) wcm.consume(in.VerificationID)
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{
"mode": "pending", "mode": "booked",
"start": rec.Start, "start": slot.Start,
"end": rec.End, "end": end,
"branch": branch, "branch": branch,
"message": "Заявка принята. Подтверждение записи ожидается от клиники.", "message": a.bookedMessage(slot.Start),
}}) }})
} }
@ -251,8 +441,6 @@ func (a *API) bookedMessage(start time.Time) string {
// repeatAppointment отвечает на повтор того же запроса (тот же request_id), не создавая записи заново. // repeatAppointment отвечает на повтор того же запроса (тот же request_id), не создавая записи заново.
func (a *API) repeatAppointment(w http.ResponseWriter, rec storage.OnecAppointment) { func (a *API) repeatAppointment(w http.ResponseWriter, rec storage.OnecAppointment) {
switch rec.Status { switch rec.Status {
case "pending":
writeJSON(w, http.StatusAccepted, envelope{Data: map[string]any{"mode": "pending", "start": rec.Start, "message": "Заявка принята. Ожидается подтверждение клиники."}})
case "booked": case "booked":
writeJSON(w, http.StatusOK, envelope{Data: map[string]any{ writeJSON(w, http.StatusOK, envelope{Data: map[string]any{
"mode": "booked", "start": rec.Start, "message": a.bookedMessage(rec.Start), "mode": "booked", "start": rec.Start, "message": a.bookedMessage(rec.Start),
@ -358,16 +546,18 @@ func (a *API) adminOnec(w http.ResponseWriter, r *http.Request, u *storage.Admin
return return
} }
ok(w, map[string]any{ ok(w, map[string]any{
"configured": a.onecAutonomousEnabled(), "configured": a.onec.Configured(),
"host": "Push/Pull", "host": a.onec.Host(),
"status": st, "status": st,
"doctors": doctors, "doctors": doctors,
}, nil) }, nil)
} }
func (a *API) adminOnecSync(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { func (a *API) adminOnecSync(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
if err := a.store.RequestOnecSnapshot(r.Context()); err != nil { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
handleErr(w, err) defer cancel()
if err := a.syncOneC(ctx, true); err != nil {
fail(w, http.StatusBadGateway, "onec_sync", onecErrText(err))
return return
} }
a.adminOnec(w, r, u) a.adminOnec(w, r, u)
@ -446,3 +636,44 @@ func (a *API) adminOnecAppointments(w http.ResponseWriter, r *http.Request, u *s
a.store.Audit(r.Context(), u.ID, "view", "onec_appointments", 0, clientIP(r)) a.store.Audit(r.Context(), u.ID, "view", "onec_appointments", 0, clientIP(r))
ok(w, list, map[string]any{"total": len(list)}) ok(w, list, map[string]any{"total": len(list)})
} }
// runOneCLongPoll держит у 1С запрос /changes: как только версия расписания выросла, окна перечитываются сразу.
func (a *API) runOneCLongPoll(ctx context.Context) {
slog.Info("onec long polling enabled")
var since int64
first := true
for ctx.Err() == nil {
wait := 25 * time.Second
if first {
wait = 0 // первый запрос только узнаёт текущую версию, не ожидая изменений
}
version, ids, err := a.onec.Changes(ctx, since, wait)
if err != nil {
slog.Warn("onec long poll", "err", err)
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
continue
}
if first {
first = false
since = version // первый ответ только запоминает версию: полный опрос уже сделан при старте
continue
}
if version <= since {
continue
}
since = version
if len(ids) == 0 {
a.onecFullResyncAsync(false)
continue
}
for _, id := range ids {
if err := a.resyncOnecDoctor(ctx, id); err != nil {
slog.Warn("onec long poll: врач не обновился", "doctor", id, "err", err)
}
}
}
}

View File

@ -1,142 +0,0 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"os"
"testing"
"time"
"dana/internal/onec"
"dana/internal/storage"
)
func TestAutonomousHTTPHandshake(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)
}
app := &API{store: st, limiter: newRateLimiter(), onecHook: "autonomy-test"}
mux := app.Routes()
call := func(method, path string, body any, want int) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest(method, path, bytes.NewReader(b))
req.Header.Set("X-Api-Key", "autonomy-test")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != want {
t.Fatalf("%s %s: %d, want %d: %s", method, path, w.Code, want, w.Body.String())
}
return w
}
start := time.Now().UTC().Truncate(time.Hour).Add(72 * time.Hour)
end := start.Add(30 * time.Minute)
from, to := start.Add(-time.Hour), end.Add(time.Hour)
doctor := fmt.Sprintf("http-autonomy-%d", time.Now().UnixNano())
slot := onec.Slot{DoctorID: doctor, BranchID: "branch", Start: start, End: end, Status: "free"}
payload := onecSyncPayload{From: &from, To: &to, Branches: []onec.Branch{}, Doctors: []onec.Doctor{}, Services: []onecDoctorServices{}, Slots: []onec.Slot{slot}}
call("POST", "/api/v1/onec/sync/all", payload, 200)
call("POST", "/api/v1/onec/sync/all", map[string]any{}, 400)
var doctorID int64
if err = st.Pool().QueryRow(ctx, `INSERT INTO doctors(slug,last_name,first_name,booking_provider) VALUES($1,'Тест','Врач','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,'Тест Врач',$2)`, doctor, doctorID); err != nil {
t.Fatal(err)
}
if err = st.SetBookingSettings(ctx, storage.BookingSettings{}); err != nil {
t.Fatal(err)
}
call("GET", "/api/v1/doctors/"+doctor+"/slots", nil, 200)
creation := appointmentInput{DoctorSlug: doctor, Start: start.Format(time.RFC3339), Name: "Тестовый пациент", Phone: "79990000000", Consent: true, RequestID: doctor, Comment: "HTTP test"}
made := call("POST", "/api/v1/appointments", creation, 201)
if !bytes.Contains(made.Body.Bytes(), []byte(`"mode":"pending"`)) {
t.Fatal("unconfirmed booking reported as booked")
}
call("POST", "/api/v1/appointments", creation, 202)
pendingRecords, err := st.PendingOnecAppointments(ctx, 50)
if err != nil {
t.Fatal(err)
}
var rec storage.OnecPendingAppointment
for _, v := range pendingRecords {
if v.RequestID == doctor {
rec = v
}
}
if rec.ID == 0 {
t.Fatal("public booking not queued")
}
call("POST", "/api/v1/onec/sync/all", payload, 200)
free := call("GET", "/api/v1/doctors/"+doctor+"/slots", nil, 200)
var freeBody struct {
Data struct {
Enabled bool `json:"enabled"`
Slots []storage.OnecSlot `json:"slots"`
} `json:"data"`
}
if err = json.Unmarshal(free.Body.Bytes(), &freeBody); err != nil {
t.Fatal(err)
}
if !freeBody.Data.Enabled || len(freeBody.Data.Slots) != 0 {
t.Fatal("push reopened reserved public slot")
}
pending := call("GET", "/api/v1/onec/appointments/pending", nil, 200)
if !bytes.Contains(pending.Body.Bytes(), []byte(doctor)) || !bytes.Contains(pending.Body.Bytes(), []byte(`"commands"`)) {
t.Fatal("pending contract incomplete")
}
uuid := "00000000-0000-0000-0000-000000000002"
confirm := confirmAppointmentReq{OnecAppointmentID: uuid, End: &end}
path := fmt.Sprintf("/api/v1/onec/appointments/%d/confirm", rec.ID)
call("POST", path, confirm, 200)
call("POST", path, confirm, 200)
confirm.OnecAppointmentID = "00000000-0000-0000-0000-000000000003"
call("POST", path, confirm, 409)
call("POST", fmt.Sprintf("/api/v1/onec/appointments/%d/reject", rec.ID), rejectAppointmentReq{Reason: "late"}, 409)
call("POST", "/api/v1/onec/events", onecEvent{Event: "schedule_changed", DoctorID: doctor}, 202)
pending = call("GET", "/api/v1/onec/appointments/pending", nil, 200)
if !bytes.Contains(pending.Body.Bytes(), []byte(`"sync_requested":true`)) {
t.Fatal("snapshot request not exposed")
}
if err = st.QueueOnecCommand(ctx, rec.ID, "cancel", "", "cancelled"); err != nil {
t.Fatal(err)
}
commands, err := st.PendingOnecCommands(ctx)
if err != nil {
t.Fatal(err)
}
var id int64
for _, c := range commands {
if c.AppointmentID == uuid {
id = c.ID
}
}
if id == 0 {
t.Fatal("cancel command missing")
}
ack := fmt.Sprintf("/api/v1/onec/commands/%d/ack", id)
call("POST", ack, map[string]any{"success": true}, 200)
call("POST", ack, map[string]any{"success": true}, 200)
call("POST", "/api/v1/onec/events", onecEvent{Event: "appointment_status", AppointmentID: uuid, Status: "cancelled"}, 200)
call("POST", path, confirm, 409)
payload.Slots = []onec.Slot{}
call("POST", "/api/v1/onec/sync/all", payload, 200)
slots, err := st.FreeOnecSlots(ctx, doctor, from, to)
if err != nil || len(slots) != 0 {
t.Fatal("empty horizon did not clear slots")
}
}

View File

@ -1,11 +1,8 @@
package api package api
import ( import (
"dana/internal/storage"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -32,11 +29,7 @@ func (a *API) checkOnecKey(r *http.Request) bool {
return false return false
} }
var uuidTextRE = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
type onecSyncPayload struct { type onecSyncPayload struct {
From *time.Time `json:"from"`
To *time.Time `json:"to"`
Branches []onec.Branch `json:"branches"` Branches []onec.Branch `json:"branches"`
Doctors []onec.Doctor `json:"doctors"` Doctors []onec.Doctor `json:"doctors"`
Services []onecDoctorServices `json:"services"` Services []onecDoctorServices `json:"services"`
@ -61,28 +54,6 @@ func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) {
return return
} }
if payload.Branches == nil || payload.Doctors == nil || payload.Services == nil || payload.Slots == nil || (payload.From == nil) != (payload.To == nil) {
fail(w, 400, "validation", "Нужен полный снимок branches/doctors/services/slots и согласованный диапазон from/to")
return
}
payload.Slots = mergeOnecSlots(payload.Slots)
from, to := onecWindow()
if payload.From != nil && payload.To != nil {
from, to = *payload.From, *payload.To
} else if len(payload.Slots) > 0 {
from, to = findSlotBounds(payload.Slots)
}
if !from.Before(to) || to.Sub(from) > 32*24*time.Hour {
fail(w, 400, "validation", "Некорректный диапазон снимка")
return
}
for _, sl := range payload.Slots {
if sl.DoctorID == "" || !sl.Start.Before(sl.End) || sl.Start.Before(from) || !sl.Start.Before(to) || (sl.Status != "free" && sl.Status != "busy") {
fail(w, 400, "validation", "Некорректный слот")
return
}
}
ctx := r.Context() ctx := r.Context()
linked := 0 linked := 0
var err error var err error
@ -98,7 +69,7 @@ func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) {
// 2. Сохраняем услуги врачей // 2. Сохраняем услуги врачей
for _, ds := range payload.Services { for _, ds := range payload.Services {
if ds.DoctorID != "" { if ds.DoctorID != "" && len(ds.Services) > 0 {
if err := a.store.SaveOnecServices(ctx, ds.DoctorID, ds.Services); err != nil { if err := a.store.SaveOnecServices(ctx, ds.DoctorID, ds.Services); err != nil {
fail(w, http.StatusInternalServerError, "db_error", "Ошибка сохранения услуг: "+err.Error()) fail(w, http.StatusInternalServerError, "db_error", "Ошибка сохранения услуг: "+err.Error())
return return
@ -106,13 +77,13 @@ func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := a.store.AcceptOnecSlots(ctx, from, to, payload.Slots); err != nil { // 3. Сохраняем слоты расписания
handleErr(w, err) if len(payload.Slots) > 0 {
minStart, maxEnd := findSlotBounds(payload.Slots)
if err := a.store.ReplaceOnecSlots(ctx, minStart, maxEnd, payload.Slots); err != nil {
fail(w, http.StatusInternalServerError, "db_error", "Ошибка сохранения слотов: "+err.Error())
return return
} }
logID, err := a.store.StartOnecSync(ctx)
if err == nil {
_ = a.store.FinishOnecSync(ctx, logID, len(payload.Doctors), len(payload.Slots), "")
} }
ok(w, map[string]any{ ok(w, map[string]any{
@ -156,18 +127,7 @@ func (a *API) onecPendingAppointments(w http.ResponseWriter, r *http.Request) {
return return
} }
commands, err := a.store.PendingOnecCommands(r.Context())
if err != nil {
handleErr(w, err)
return
}
requested, err := a.store.OnecSnapshotRequested(r.Context())
if err != nil {
handleErr(w, err)
return
}
ok(w, map[string]any{ ok(w, map[string]any{
"commands": commands, "sync_requested": requested,
"ok": true, "ok": true,
"appointments": list, "appointments": list,
}, nil) }, nil)
@ -198,17 +158,13 @@ func (a *API) onecConfirmAppointment(w http.ResponseWriter, r *http.Request) {
return return
} }
if !uuidTextRE.MatchString(req.OnecAppointmentID) { if req.OnecAppointmentID == "" {
fail(w, http.StatusBadRequest, "missing_param", "onec_appointment_id обязателен") fail(w, http.StatusBadRequest, "missing_param", "onec_appointment_id обязателен")
return return
} }
if err := a.store.ConfirmPendingAppointment(r.Context(), id, req.OnecAppointmentID, req.End); err != nil { if err := a.store.ConfirmPendingAppointment(r.Context(), id, req.OnecAppointmentID, req.End); err != nil {
if errors.Is(err, storage.ErrOnecTransition) { fail(w, http.StatusInternalServerError, "db_error", err.Error())
fail(w, 409, "state_conflict", "Заявка уже обработана иначе")
return
}
handleErr(w, err)
return return
} }
@ -244,66 +200,9 @@ func (a *API) onecRejectAppointment(w http.ResponseWriter, r *http.Request) {
} }
if err := a.store.RejectPendingAppointment(r.Context(), id, req.Reason); err != nil { if err := a.store.RejectPendingAppointment(r.Context(), id, req.Reason); err != nil {
if errors.Is(err, storage.ErrOnecTransition) {
fail(w, 409, "state_conflict", "Заявка уже обработана иначе")
return
}
fail(w, http.StatusInternalServerError, "db_error", err.Error()) fail(w, http.StatusInternalServerError, "db_error", err.Error())
return return
} }
if a.bookingConfirmEnabled() {
if err := a.store.MarkLeadOnly(r.Context(), id); err != nil {
handleErr(w, err)
return
}
a.kickConfirm()
}
ok(w, map[string]any{"ok": true, "id": id, "status": "failed"}, nil) ok(w, map[string]any{"ok": true, "id": id, "status": "failed"}, nil)
} }
func (a *API) onecCommandAck(w http.ResponseWriter, r *http.Request) {
if !a.checkOnecKey(r) {
fail(w, 401, "unauthorized", "Неверный ключ")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
fail(w, 400, "bad_id", "Некорректная команда")
return
}
var body struct {
Success bool `json:"success"`
Error string `json:"error"`
}
if err = json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&body); err != nil {
fail(w, 400, "bad_json", "Некорректный JSON")
return
}
if err = a.store.AckOnecCommand(r.Context(), id, body.Success, body.Error); err != nil {
handleErr(w, err)
return
}
ok(w, map[string]any{"ok": true}, nil)
}
// Overlapping shifts can repeat the same key. A busy occurrence always wins.
func mergeOnecSlots(slots []onec.Slot) []onec.Slot {
out := make([]onec.Slot, 0, len(slots))
seen := map[string]int{}
for _, sl := range slots {
key := sl.DoctorID + "/" + sl.BranchID + "/" + sl.Start.UTC().Format(time.RFC3339Nano)
if i, ok := seen[key]; ok {
if sl.Status == "busy" {
out[i].Status = "busy"
}
if sl.End.After(out[i].End) {
out[i].End = sl.End
}
continue
}
seen[key] = len(out)
out = append(out, sl)
}
return out
}

View File

@ -67,17 +67,3 @@ func TestOnecConfirmBadID(t *testing.T) {
t.Fatalf("expected 400 bad id, got %d", w.Code) t.Fatalf("expected 400 bad id, got %d", w.Code)
} }
} }
func TestPushMergesDuplicateSlotsConservatively(t *testing.T) {
start := time.Now().UTC()
end := start.Add(time.Hour)
free := onec.Slot{DoctorID: "d", BranchID: "b", Start: start, End: end, Status: "free"}
busy := free
busy.Status = "busy"
for _, slots := range [][]onec.Slot{{free, busy}, {busy, free}} {
got := mergeOnecSlots(slots)
if len(got) != 1 || got[0].Status != "busy" {
t.Fatalf("busy duplicate lost: %+v", got)
}
}
}

View File

@ -1,81 +1,135 @@
package api package api
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"strings" "strings"
"sync/atomic"
"time" "time"
) )
// Входящие события от 1С. Опрос раз в 5 минут остаётся основным способом, вебхук только ускоряет обновление:
// 1С сообщает, что расписание врача изменилось или запись сменила статус, а сайт сразу перечитывает данные из 1С.
// Данные из тела события в расписание не пишутся — источник правды всегда ответ API 1С.
const maxOnecEventBody = 8 << 10 const maxOnecEventBody = 8 << 10
// WithOneCWebhook включает приём событий от 1С; пустой ключ — вебхук выключен.
func (a *API) WithOneCWebhook(key string) { a.onecHook = strings.TrimSpace(key) } func (a *API) WithOneCWebhook(key string) { a.onecHook = strings.TrimSpace(key) }
func (a *API) onecAutonomousEnabled() bool { return a.onecHook != "" }
var onecEventSyncBusy atomic.Bool
type onecEvent struct { type onecEvent struct {
Event string `json:"event"` Event string `json:"event"`
DoctorID string `json:"doctor_id"` DoctorID string `json:"doctor_id"`
AppointmentID string `json:"appointment_id"` AppointmentID string `json:"appointment_id"`
Status string `json:"status"` Status string `json:"status"`
BranchID string `json:"branch_id"`
Start *time.Time `json:"start"`
End *time.Time `json:"end"`
} }
func sameKey(got, want string) bool { func sameKey(got, want string) bool {
g, w := sha256.Sum256([]byte(got)), sha256.Sum256([]byte(want)) g, w := sha256.Sum256([]byte(got)), sha256.Sum256([]byte(want))
return subtle.ConstantTimeCompare(g[:], w[:]) == 1 return subtle.ConstantTimeCompare(g[:], w[:]) == 1
} }
func (a *API) onecEvents(w http.ResponseWriter, r *http.Request) { func (a *API) onecEvents(w http.ResponseWriter, r *http.Request) {
if !a.onecAutonomousEnabled() { if a.onecHook == "" || !a.onec.Configured() {
fail(w, 404, "not_found", "Не найдено") fail(w, http.StatusNotFound, "not_found", "Не найдено")
return return
} }
ip := clientIP(r) ip := clientIP(r)
if a.tooMany(w, "onec-ev:"+ip, 300, time.Minute) { if a.tooMany(w, "onec-ev:"+ip, 300, time.Minute) {
return return
} }
if !a.checkOnecKey(r) { key := r.Header.Get("X-Api-Key")
if key == "" {
key = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
if !sameKey(key, a.onecHook) {
// неверные ключи считаем отдельно: перебор упирается в лимит
if a.tooMany(w, "onec-bad:"+ip, 20, 10*time.Minute) { if a.tooMany(w, "onec-bad:"+ip, 20, 10*time.Minute) {
return return
} }
fail(w, 401, "unauthorized", "Неверный ключ") fail(w, http.StatusUnauthorized, "unauthorized", "Неверный ключ")
return return
} }
var ev onecEvent var ev onecEvent
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxOnecEventBody)).Decode(&ev); err != nil { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxOnecEventBody)).Decode(&ev); err != nil {
fail(w, 400, "bad_json", "Не удалось разобрать событие") fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать событие")
return return
} }
ev.DoctorID = strings.TrimSpace(ev.DoctorID)
switch ev.Event { switch ev.Event {
case "schedule_changed", "directory_changed": case "schedule_changed":
if err := a.store.RequestOnecSnapshot(r.Context()); err != nil { if ev.DoctorID == "" {
handleErr(w, err) a.onecFullResyncAsync(false)
writeJSON(w, http.StatusAccepted, envelope{Data: map[string]any{"ok": true, "queued": "schedule"}})
return return
} }
writeJSON(w, http.StatusAccepted, envelope{Data: map[string]any{"ok": true, "queued": "push"}}) if err := a.resyncOnecDoctor(r.Context(), ev.DoctorID); err != nil {
fail(w, http.StatusBadGateway, "onec_sync", onecErrText(err))
return
}
ok(w, map[string]any{"ok": true}, nil)
case "directory_changed":
a.onecFullResyncAsync(true)
writeJSON(w, http.StatusAccepted, envelope{Data: map[string]any{"ok": true, "queued": "directory"}})
case "appointment_status": case "appointment_status":
if ev.AppointmentID == "" || (ev.Status != "booked" && ev.Status != "arrived" && ev.Status != "cancelled") { if ev.AppointmentID == "" || (ev.Status != "booked" && ev.Status != "arrived" && ev.Status != "cancelled") {
fail(w, 400, "validation", "Нужны appointment_id и status: booked, arrived или cancelled") fail(w, http.StatusBadRequest, "validation", "Нужны appointment_id и status: booked, arrived или cancelled")
return return
} }
if (ev.Start == nil) != (ev.End == nil) || (ev.Start != nil && !ev.Start.Before(*ev.End)) { doctorID, _, found, err := a.store.SetOnecAppointmentStatus(r.Context(), ev.AppointmentID, ev.Status)
fail(w, 400, "validation", "Некорректный интервал события")
return
}
_, _, known, err := a.store.ApplyOnecAppointmentEvent(r.Context(), ev.AppointmentID, ev.Status, ev.DoctorID, ev.BranchID, ev.Start, ev.End)
if err != nil { if err != nil {
handleErr(w, err) handleErr(w, err)
return return
} }
if err = a.store.RequestOnecSnapshot(r.Context()); err != nil { if !found {
handleErr(w, err) // запись сделана не с сайта — нам она не нужна, но для 1С это не ошибка
ok(w, map[string]any{"ok": true, "known": false}, nil)
return return
} }
ok(w, map[string]any{"ok": true, "known": known}, nil) if ev.Status == "cancelled" {
if err := a.resyncOnecDoctor(r.Context(), doctorID); err != nil {
slog.Warn("onec webhook: окна после отмены не обновились", "doctor", doctorID, "err", err)
}
}
ok(w, map[string]any{"ok": true, "known": true}, nil)
default: default:
fail(w, 400, "validation", "Неизвестное событие: допустимы schedule_changed, directory_changed, appointment_status") fail(w, http.StatusBadRequest, "validation", "Неизвестное событие: допустимы schedule_changed, directory_changed, appointment_status")
} }
} }
// resyncOnecDoctor заново забирает окна одного врача на весь горизонт.
func (a *API) resyncOnecDoctor(ctx context.Context, onecID string) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
from, to := onecWindow()
slots, err := a.onec.Schedule(ctx, from, to, onecID)
if err != nil {
return err
}
return a.store.ReplaceOnecDoctorSlots(ctx, onecID, from, to, slots)
}
// onecFullResyncAsync запускает полную синхронизацию в фоне, чтобы 1С не ждала ответа; пока идёт одна — вторая не ставится.
func (a *API) onecFullResyncAsync(dir bool) {
if !onecEventSyncBusy.CompareAndSwap(false, true) {
return
}
go func() {
defer onecEventSyncBusy.Store(false)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := a.syncOneC(ctx, dir); err != nil {
slog.Warn("onec webhook sync", "err", err)
}
}()
}

View File

@ -58,11 +58,3 @@ func TestOnecEventsBruteForceLimit(t *testing.T) {
t.Fatalf("перебор ключа должен упираться в лимит, получили %d", last) t.Fatalf("перебор ключа должен упираться в лимит, получили %d", last)
} }
} }
func TestAutonomousWebhookValidationWithoutBridge(t *testing.T) {
a := &API{limiter: newRateLimiter()}
a.WithOneCWebhook("secret")
if rec := postEvent(a, "secret", `{"event":"unsupported"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("bridge-free route disabled: %d", rec.Code)
}
}

View File

@ -52,7 +52,6 @@ func (s *Storage) SetBookingSettings(ctx context.Context, b BookingSettings) err
// ConfirmItem — запись с сайта, ждущая решения оператора, со всем, что нужно для лида в Битриксе и CRM. // ConfirmItem — запись с сайта, ждущая решения оператора, со всем, что нужно для лида в Битриксе и CRM.
type ConfirmItem struct { type ConfirmItem struct {
Comment string
ID int64 ID int64
Start time.Time Start time.Time
CreatedAt time.Time CreatedAt time.Time
@ -74,7 +73,7 @@ const confirmItemSelect = `
SELECT a.id, a.start_at, a.created_at, a.patient_name, a.patient_phone, a.doctor_onec_id, SELECT a.id, a.start_at, a.created_at, a.patient_name, a.patient_phone, a.doctor_onec_id,
COALESCE(NULLIF(o.full_name, ''), a.doctor_onec_id), COALESCE(NULLIF(b.address, ''), b.name, ''), COALESCE(NULLIF(o.full_name, ''), a.doctor_onec_id), COALESCE(NULLIF(b.address, ''), b.name, ''),
COALESCE(sv.name, ''), a.onec_appointment_id, a.bitrix_lead_id, a.error, COALESCE(sv.name, ''), a.onec_appointment_id, a.bitrix_lead_id, a.error,
a.crm_deal_id, a.crm_confirm_state, a.crm_error, a.patient_comment a.crm_deal_id, a.crm_confirm_state, a.crm_error
FROM onec_appointments a FROM onec_appointments a
LEFT JOIN onec_doctors o ON o.onec_id = a.doctor_onec_id LEFT JOIN onec_doctors o ON o.onec_id = a.doctor_onec_id
LEFT JOIN onec_branches b ON b.onec_id = a.branch_onec_id LEFT JOIN onec_branches b ON b.onec_id = a.branch_onec_id
@ -134,7 +133,7 @@ func (s *Storage) confirmItems(ctx context.Context, query string, limit int) ([]
var c ConfirmItem var c ConfirmItem
if err := rows.Scan(&c.ID, &c.Start, &c.CreatedAt, &c.Patient, &c.Phone, &c.DoctorOnec, &c.DoctorName, if err := rows.Scan(&c.ID, &c.Start, &c.CreatedAt, &c.Patient, &c.Phone, &c.DoctorOnec, &c.DoctorName,
&c.Branch, &c.ServiceName, &c.OnecID, &c.BitrixLeadID, &c.Error, &c.Branch, &c.ServiceName, &c.OnecID, &c.BitrixLeadID, &c.Error,
&c.CrmDealID, &c.CrmConfirmState, &c.CrmError, &c.Comment); err != nil { &c.CrmDealID, &c.CrmConfirmState, &c.CrmError); err != nil {
return nil, err return nil, err
} }
out = append(out, c) out = append(out, c)

View File

@ -1,23 +0,0 @@
-- Durable control plane for outbound-only 1C polling. No clinical data is removed.
ALTER TABLE onec_appointments ADD COLUMN patient_comment text NOT NULL DEFAULT '';
ALTER TABLE onec_appointments ADD COLUMN patient_birth_date text NOT NULL DEFAULT '';
CREATE INDEX onec_appointments_occupancy_idx ON onec_appointments(doctor_onec_id,start_at)
WHERE status IN ('pending','booked','arrived');
CREATE TABLE onec_push_state (
singleton boolean PRIMARY KEY DEFAULT true CHECK(singleton),
last_snapshot_at timestamptz,
sync_requested boolean NOT NULL DEFAULT true
);
INSERT INTO onec_push_state(singleton) VALUES(true);
CREATE TABLE onec_commands (
id bigserial PRIMARY KEY,
appointment_id bigint NOT NULL REFERENCES onec_appointments(id),
kind text NOT NULL CHECK(kind IN ('cancel','link_bitrix','link_crm')),
value text NOT NULL DEFAULT '',
final_state text NOT NULL DEFAULT '',
completed_at timestamptz,
notification_done boolean NOT NULL DEFAULT false,
last_error text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(appointment_id,kind)
);

View File

@ -2,7 +2,6 @@ package storage
import ( import (
"context" "context"
"errors"
"strings" "strings"
"time" "time"
@ -11,9 +10,6 @@ import (
"dana/internal/onec" "dana/internal/onec"
) )
var ErrOnecSlotBusy = errors.New("slot already reserved")
var ErrOnecTransition = errors.New("appointment state conflict")
var moscow = time.FixedZone("Europe/Moscow", 3*60*60) var moscow = time.FixedZone("Europe/Moscow", 3*60*60)
// ---------- Справочники ---------- // ---------- Справочники ----------
@ -256,10 +252,7 @@ func (s *Storage) FreeOnecSlots(ctx context.Context, doctorOnecID string, from,
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT sl.start_at, sl.end_at, sl.branch_onec_id, COALESCE(NULLIF(b.address, ''), b.name, ''), sl.cabinet, sl.slot_id SELECT sl.start_at, sl.end_at, sl.branch_onec_id, COALESCE(NULLIF(b.address, ''), b.name, ''), sl.cabinet, sl.slot_id
FROM onec_slots sl LEFT JOIN onec_branches b ON b.onec_id = sl.branch_onec_id FROM onec_slots sl LEFT JOIN onec_branches b ON b.onec_id = sl.branch_onec_id
WHERE sl.doctor_onec_id=$1 AND sl.status='free' AND sl.start_at >= greatest($2::timestamptz, now()) AND sl.start_at < $3 AND NOT EXISTS ( WHERE sl.doctor_onec_id=$1 AND sl.status='free' AND sl.start_at >= greatest($2::timestamptz, now()) AND sl.start_at < $3
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)
ORDER BY sl.start_at, sl.branch_onec_id`, doctorOnecID, from, to) ORDER BY sl.start_at, sl.branch_onec_id`, doctorOnecID, from, to)
if err != nil { if err != nil {
return nil, err return nil, err
@ -363,7 +356,8 @@ func (s *Storage) OnecSyncStatus(ctx context.Context) (OnecSyncStatus, error) {
// OnecFresh сообщает, была ли успешная синхронизация не раньше чем maxAge назад. // OnecFresh сообщает, была ли успешная синхронизация не раньше чем maxAge назад.
func (s *Storage) OnecFresh(ctx context.Context, maxAge time.Duration) bool { func (s *Storage) OnecFresh(ctx context.Context, maxAge time.Duration) bool {
var ok bool var ok bool
_ = s.pool.QueryRow(ctx, `SELECT last_snapshot_at > now() - $1::interval FROM onec_push_state WHERE singleton`, maxAge.String()).Scan(&ok) _ = s.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM onec_sync_log WHERE finished_at > now() - $1::interval AND error = '')`,
maxAge.String()).Scan(&ok)
return ok return ok
} }
@ -513,8 +507,6 @@ type OnecAppointment struct {
ServiceName string `json:"service_name"` ServiceName string `json:"service_name"`
ServiceID string `json:"-"` ServiceID string `json:"-"`
BranchID string `json:"-"` BranchID string `json:"-"`
Comment string `json:"-"`
BirthDate string `json:"-"`
// подтверждение оператором в Битрикс24 (см. booking_confirm.go) // подтверждение оператором в Битрикс24 (см. booking_confirm.go)
ConfirmState string `json:"confirm_state"` ConfirmState string `json:"confirm_state"`
BitrixLeadID string `json:"bitrix_lead_id"` BitrixLeadID string `json:"bitrix_lead_id"`
@ -528,57 +520,19 @@ type OnecAppointment struct {
// BeginOnecAppointment создаёт запись в статусе pending. Если такой request_id уже был, возвращает прежнюю запись // BeginOnecAppointment создаёт запись в статусе pending. Если такой request_id уже был, возвращает прежнюю запись
// и created=false — так повтор запроса не создаёт вторую запись в 1С. // и created=false — так повтор запроса не создаёт вторую запись в 1С.
func (s *Storage) BeginOnecAppointment(ctx context.Context, a OnecAppointment) (rec OnecAppointment, created bool, err error) { func (s *Storage) BeginOnecAppointment(ctx context.Context, a OnecAppointment) (rec OnecAppointment, created bool, err error) {
tx, err := s.pool.Begin(ctx) err = s.pool.QueryRow(ctx, `
if err != nil { INSERT INTO onec_appointments (request_id, doctor_onec_id, branch_onec_id, service_onec_id, start_at, patient_name, patient_phone)
VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (request_id) DO NOTHING RETURNING id`,
a.RequestID, a.DoctorOnec, a.BranchID, a.ServiceID, a.Start, a.Patient, a.Phone).Scan(&a.ID)
if err == nil {
a.Status = "pending"
return a, true, nil
}
if wrapNotFound(err) != ErrNotFound {
return a, false, err return a, false, err
} }
defer tx.Rollback(ctx)
// Request lock makes retries safe even if doctor/slot in the request changed.
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "onec-request:"+a.RequestID); err != nil {
return a, false, err
}
var existing bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM onec_appointments WHERE request_id=$1)`, a.RequestID).Scan(&existing); err != nil {
return a, false, err
}
if existing {
rec, err = s.onecAppointmentByRequest(ctx, a.RequestID) rec, err = s.onecAppointmentByRequest(ctx, a.RequestID)
return rec, false, err return rec, false, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "onec-doctor:"+a.DoctorOnec); err != nil {
return a, false, err
}
var end time.Time
if a.Status != "failed" {
a.Status = "pending"
}
if a.Status == "pending" {
err = tx.QueryRow(ctx, `SELECT sl.branch_onec_id,sl.end_at FROM onec_slots sl
WHERE sl.doctor_onec_id=$1 AND sl.start_at=$2 AND ($3='' OR sl.branch_onec_id=$3)
AND sl.status='free' 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)
ORDER BY sl.branch_onec_id LIMIT 1`, a.DoctorOnec, a.Start, a.BranchID).Scan(&a.BranchID, &end)
if errors.Is(err, pgx.ErrNoRows) {
return a, false, ErrOnecSlotBusy
}
if err != nil {
return a, false, err
}
a.End = &end
}
err = tx.QueryRow(ctx, `INSERT INTO onec_appointments
(request_id,doctor_onec_id,branch_onec_id,service_onec_id,start_at,end_at,patient_name,patient_phone,patient_comment,patient_birth_date,status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id`,
a.RequestID, a.DoctorOnec, a.BranchID, a.ServiceID, a.Start, a.End, a.Patient, a.Phone, a.Comment, a.BirthDate, a.Status).Scan(&a.ID)
if err != nil {
return a, false, err
}
if err = tx.Commit(ctx); err != nil {
return a, false, err
}
return a, true, nil
} }
func (s *Storage) onecAppointmentByRequest(ctx context.Context, requestID string) (OnecAppointment, error) { func (s *Storage) onecAppointmentByRequest(ctx context.Context, requestID string) (OnecAppointment, error) {
@ -631,16 +585,10 @@ func (s *Storage) OnecAppointments(ctx context.Context, limit int) ([]OnecAppoin
// SetOnecAppointmentStatus обновляет статус записи по номеру записи в 1С (вебхук 1С). // SetOnecAppointmentStatus обновляет статус записи по номеру записи в 1С (вебхук 1С).
// Возвращает врача и время приёма, чтобы пересчитать его окна. // Возвращает врача и время приёма, чтобы пересчитать его окна.
func (s *Storage) SetOnecAppointmentStatus(ctx context.Context, onecAppointmentID, status string) (doctorOnecID string, start time.Time, found bool, err error) { func (s *Storage) SetOnecAppointmentStatus(ctx context.Context, onecAppointmentID, status string) (doctorOnecID string, start time.Time, found bool, err error) {
return s.ApplyOnecAppointmentEvent(ctx, onecAppointmentID, status, "", "", nil, nil)
}
func (s *Storage) ApplyOnecAppointmentEvent(ctx context.Context, onecAppointmentID, status, doctor, branch string, eventStart, eventEnd *time.Time) (doctorOnecID string, start time.Time, found bool, err error) {
err = s.pool.QueryRow(ctx, ` err = s.pool.QueryRow(ctx, `
UPDATE onec_appointments SET status=$2, updated_at=now(), UPDATE onec_appointments SET status=$2, updated_at=now()
doctor_onec_id=COALESCE(NULLIF($3,''),doctor_onec_id),branch_onec_id=COALESCE(NULLIF($4,''),branch_onec_id), WHERE onec_appointment_id=$1 AND status IN ('booked','arrived','cancelled')
start_at=COALESCE($5,start_at),end_at=COALESCE($6,end_at), RETURNING doctor_onec_id, start_at`, onecAppointmentID, status).Scan(&doctorOnecID, &start)
confirm_state=CASE WHEN $2='cancelled' AND confirm_state<>'expired' THEN 'cancelled' ELSE confirm_state END
WHERE onec_appointment_id=$1 AND status IN ('booked','arrived','cancelled') AND (status<>'cancelled' OR $2='cancelled') AND (status<>'arrived' OR $2<>'booked')
RETURNING doctor_onec_id, start_at`, onecAppointmentID, status, doctor, branch, eventStart, eventEnd).Scan(&doctorOnecID, &start)
if err != nil { if err != nil {
if wrapNotFound(err) == ErrNotFound { if wrapNotFound(err) == ErrNotFound {
return "", time.Time{}, false, nil return "", time.Time{}, false, nil
@ -660,8 +608,6 @@ type OnecPendingAppointment struct {
Patient string `json:"patient_name"` Patient string `json:"patient_name"`
Phone string `json:"patient_phone"` Phone string `json:"patient_phone"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Comment string `json:"comment"`
BirthDate string `json:"birth_date"`
} }
func (s *Storage) PendingOnecAppointments(ctx context.Context, limit int) ([]OnecPendingAppointment, error) { func (s *Storage) PendingOnecAppointments(ctx context.Context, limit int) ([]OnecPendingAppointment, error) {
@ -669,7 +615,7 @@ func (s *Storage) PendingOnecAppointments(ctx context.Context, limit int) ([]One
limit = 20 limit = 20
} }
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, request_id, doctor_onec_id, branch_onec_id, COALESCE(service_onec_id, ''), start_at, patient_name, patient_phone, created_at, patient_comment, patient_birth_date SELECT id, request_id, doctor_onec_id, branch_onec_id, COALESCE(service_onec_id, ''), start_at, patient_name, patient_phone, created_at
FROM onec_appointments FROM onec_appointments
WHERE status = 'pending' WHERE status = 'pending'
ORDER BY id ASC ORDER BY id ASC
@ -681,39 +627,26 @@ func (s *Storage) PendingOnecAppointments(ctx context.Context, limit int) ([]One
out := []OnecPendingAppointment{} out := []OnecPendingAppointment{}
for rows.Next() { for rows.Next() {
var a OnecPendingAppointment var a OnecPendingAppointment
if err := rows.Scan(&a.ID, &a.RequestID, &a.DoctorID, &a.BranchID, &a.ServiceID, &a.Start, &a.Patient, &a.Phone, &a.CreatedAt, &a.Comment, &a.BirthDate); err != nil { if err := rows.Scan(&a.ID, &a.RequestID, &a.DoctorID, &a.BranchID, &a.ServiceID, &a.Start, &a.Patient, &a.Phone, &a.CreatedAt); err != nil {
return nil, err return nil, err
} }
a.Start = a.Start.In(moscow)
out = append(out, a) out = append(out, a)
} }
return out, rows.Err() return out, rows.Err()
} }
func (s *Storage) ConfirmPendingAppointment(ctx context.Context, id int64, onecID string, end *time.Time) error { func (s *Storage) ConfirmPendingAppointment(ctx context.Context, id int64, onecID string, end *time.Time) error {
tag, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET status='booked',onec_appointment_id=$2, _, err := s.pool.Exec(ctx, `
end_at=COALESCE($3,end_at),updated_at=now() WHERE id=$1 AND UPDATE onec_appointments
COALESCE($3,end_at)>start_at AND (status='pending' OR (status='booked' AND onec_appointment_id=$2 AND ($3::timestamptz IS NULL OR end_at=$3)))`, id, onecID, end) SET status = 'booked', onec_appointment_id = $2, end_at = $3, updated_at = now()
if err != nil { WHERE id = $1 AND status = 'pending'`, id, onecID, end)
return err return err
}
if tag.RowsAffected() == 0 {
return ErrOnecTransition
}
return nil
} }
func (s *Storage) RejectPendingAppointment(ctx context.Context, id int64, reason string) error { func (s *Storage) RejectPendingAppointment(ctx context.Context, id int64, reason string) error {
tag, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET status='failed',error=$2,updated_at=now() _, err := s.pool.Exec(ctx, `
WHERE id=$1 AND status IN ('pending','failed')`, id, reason) UPDATE onec_appointments
if err != nil { SET status = 'failed', error = $2, updated_at = now()
return err WHERE id = $1 AND status = 'pending'`, id, reason)
}
if tag.RowsAffected() == 0 {
return ErrOnecTransition
}
return nil
}
func (s *Storage) AttachOnecLead(ctx context.Context, id int64, lead *int64) error {
_, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET lead_id=COALESCE($2,lead_id) WHERE id=$1`, id, lead)
return err return err
} }

View File

@ -1,157 +0,0 @@
package storage
import (
"context"
"errors"
"time"
"dana/internal/onec"
"github.com/jackc/pgx/v5"
)
func (s *Storage) RequestOnecSnapshot(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `UPDATE onec_push_state SET sync_requested=true WHERE singleton`)
return err
}
func (s *Storage) OnecSnapshotRequested(ctx context.Context) (bool, error) {
var v bool
err := s.pool.QueryRow(ctx, `SELECT sync_requested FROM onec_push_state WHERE singleton`).Scan(&v)
return v, err
}
// Replacing even an empty horizon and marking it fresh are one transaction.
func (s *Storage) AcceptOnecSlots(ctx context.Context, from, to time.Time, slots []onec.Slot) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(719472109)`); err != nil {
return err
}
if _, err = tx.Exec(ctx, `DELETE FROM onec_slots WHERE (start_at >= $1 AND start_at < $2) OR start_at < now()-interval '1 day'`, from, to); err != nil {
return err
}
if err = insertSlots(ctx, tx, from, to, slots); err != nil {
return err
}
if _, err = tx.Exec(ctx, `UPDATE onec_push_state SET last_snapshot_at=now(),sync_requested=false WHERE singleton`); err != nil {
return err
}
return tx.Commit(ctx)
}
type OnecCommand struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
AppointmentID string `json:"appointment_id"`
Value string `json:"value"`
}
func (s *Storage) QueueOnecCommand(ctx context.Context, appointmentID int64, kind, value, finalState string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `INSERT INTO onec_commands(appointment_id,kind,value,final_state)
SELECT id,$2,$3,$4 FROM onec_appointments WHERE id=$1 AND onec_appointment_id<>''
ON CONFLICT(appointment_id,kind) DO UPDATE SET value=EXCLUDED.value
WHERE onec_commands.completed_at IS NULL`, appointmentID, kind, value, finalState)
if err != nil {
return err
}
if kind == "cancel" {
if _, err = tx.Exec(ctx, `UPDATE onec_appointments SET confirm_state='cancel_pending',updated_at=now() WHERE id=$1 AND status='booked'
AND EXISTS(SELECT 1 FROM onec_commands WHERE appointment_id=$1 AND kind='cancel' AND completed_at IS NULL)`, appointmentID); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (s *Storage) PendingOnecCommands(ctx context.Context) ([]OnecCommand, error) {
rows, err := s.pool.Query(ctx, `SELECT c.id,c.kind,a.onec_appointment_id,c.value FROM onec_commands c
JOIN onec_appointments a ON a.id=c.appointment_id WHERE c.completed_at IS NULL ORDER BY c.id LIMIT 50`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []OnecCommand{}
for rows.Next() {
var c OnecCommand
if err = rows.Scan(&c.ID, &c.Kind, &c.AppointmentID, &c.Value); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Storage) AckOnecCommand(ctx context.Context, id int64, success bool, message string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var kind, state string
var appointmentID int64
var completed *time.Time
err = tx.QueryRow(ctx, `SELECT kind,appointment_id,final_state,completed_at FROM onec_commands WHERE id=$1 FOR UPDATE`, id).Scan(&kind, &appointmentID, &state, &completed)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if completed != nil {
return nil
}
if !success {
_, err = tx.Exec(ctx, `UPDATE onec_commands SET last_error=$2 WHERE id=$1`, id, message)
if err != nil {
return err
}
return tx.Commit(ctx)
}
if kind == "cancel" {
_, err = tx.Exec(ctx, `UPDATE onec_appointments SET status='cancelled',confirm_state=$2,confirm_error='',updated_at=now() WHERE id=$1 AND status IN ('booked','cancelled')`, appointmentID, state)
if err != nil {
return err
}
if _, err = tx.Exec(ctx, `UPDATE onec_push_state SET sync_requested=true WHERE singleton`); err != nil {
return err
}
}
if _, err = tx.Exec(ctx, `UPDATE onec_commands SET completed_at=now(),last_error='' WHERE id=$1`, id); err != nil {
return err
}
return tx.Commit(ctx)
}
// Expiry in B24 is retried independently after 1C has confirmed the cancellation.
type ExpiredOnecLead struct {
CommandID int64
LeadID string
}
func (s *Storage) ExpiredOnecLeads(ctx context.Context) ([]ExpiredOnecLead, error) {
rows, err := s.pool.Query(ctx, `SELECT c.id,a.bitrix_lead_id FROM onec_commands c JOIN onec_appointments a ON a.id=c.appointment_id
WHERE c.kind='cancel' AND c.final_state='expired' AND c.completed_at IS NOT NULL AND NOT c.notification_done AND a.bitrix_lead_id<>'' LIMIT 100`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ExpiredOnecLead{}
for rows.Next() {
var v ExpiredOnecLead
if err = rows.Scan(&v.CommandID, &v.LeadID); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (s *Storage) MarkExpiredOnecLead(ctx context.Context, id int64) error {
_, err := s.pool.Exec(ctx, `UPDATE onec_commands SET notification_done=true WHERE id=$1`, id)
return err
}

View File

@ -1,149 +0,0 @@
package storage
import (
"context"
"errors"
"fmt"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"dana/internal/onec"
)
// Run only against the disposable DB created by the autonomy acceptance command.
func TestAutonomousBookingLifecycle(t *testing.T) {
dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN")
if dsn == "" {
t.Skip("requires disposable autonomy database")
}
ctx := context.Background()
s, err := New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err = s.Migrate(ctx); err != nil {
t.Fatal(err)
}
must := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
doctor := fmt.Sprintf("autonomy-%d", time.Now().UnixNano())
start := time.Now().UTC().Truncate(time.Hour).Add(48 * time.Hour)
end := start.Add(30 * time.Minute)
slot := onec.Slot{DoctorID: doctor, BranchID: "branch", Start: start, End: end, Status: "free"}
from, to := start.Add(-time.Hour), end.Add(time.Hour)
must(s.AcceptOnecSlots(ctx, from, to, []onec.Slot{slot}))
if !s.OnecFresh(ctx, time.Hour) {
t.Fatal("push did not mark freshness")
}
var wins atomic.Int32
var winner OnecAppointment
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 12; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
a, created, err := s.BeginOnecAppointment(ctx, OnecAppointment{RequestID: fmt.Sprintf("%s-%d", doctor, i), DoctorOnec: doctor, BranchID: "branch", Start: start, Patient: "test", Phone: "test", Comment: "test no calls"})
if errors.Is(err, ErrOnecSlotBusy) {
return
}
if err != nil {
t.Error(err)
return
}
if !created {
t.Error("unexpected duplicate")
return
}
wins.Add(1)
mu.Lock()
winner = a
mu.Unlock()
}(i)
}
wg.Wait()
if wins.Load() != 1 {
t.Fatalf("concurrent reservation winners=%d", wins.Load())
}
assertFree := func(n int) {
t.Helper()
v, err := s.FreeOnecSlots(ctx, doctor, from, to)
must(err)
if len(v) != n {
t.Fatalf("free slots=%d want %d", len(v), n)
}
}
assertFree(0)
same, created, err := s.BeginOnecAppointment(ctx, OnecAppointment{RequestID: winner.RequestID, DoctorOnec: doctor, Start: start})
must(err)
if created || same.ID != winner.ID {
t.Fatal("request retry duplicated booking")
}
must(s.AcceptOnecSlots(ctx, from, to, []onec.Slot{slot}))
assertFree(0) // stale free snapshot cannot reopen pending
pending, err := s.PendingOnecAppointments(ctx, 50)
must(err)
found := false
for _, p := range pending {
if p.ID == winner.ID {
found = true
if p.Start.Format("-07:00") != "+03:00" || p.Comment != "test no calls" {
t.Fatalf("bad pull fields: %+v", p)
}
}
}
if !found {
t.Fatal("pending booking missing")
}
uuid := "00000000-0000-0000-0000-000000000001"
must(s.ConfirmPendingAppointment(ctx, winner.ID, uuid, &end))
must(s.ConfirmPendingAppointment(ctx, winner.ID, uuid, &end))
if !errors.Is(s.ConfirmPendingAppointment(ctx, winner.ID, "different", &end), ErrOnecTransition) {
t.Fatal("mismatched confirm accepted")
}
if !errors.Is(s.RejectPendingAppointment(ctx, winner.ID, "late reject"), ErrOnecTransition) {
t.Fatal("late reject accepted")
}
must(s.AcceptOnecSlots(ctx, from, to, []onec.Slot{slot}))
assertFree(0) // booked also survives full Push
must(s.QueueOnecCommand(ctx, winner.ID, "link_bitrix", "123", ""))
must(s.QueueOnecCommand(ctx, winner.ID, "link_bitrix", "123", ""))
commands, err := s.PendingOnecCommands(ctx)
must(err)
if len(commands) != 1 {
t.Fatal("duplicate link commands")
}
must(s.AckOnecCommand(ctx, commands[0].ID, false, "retry"))
commands, err = s.PendingOnecCommands(ctx)
must(err)
if len(commands) != 1 {
t.Fatal("failed ACK lost command")
}
must(s.AckOnecCommand(ctx, commands[0].ID, true, ""))
must(s.AckOnecCommand(ctx, commands[0].ID, true, ""))
must(s.QueueOnecCommand(ctx, winner.ID, "cancel", "", "cancelled"))
assertFree(0)
commands, err = s.PendingOnecCommands(ctx)
must(err)
if len(commands) != 1 || commands[0].Kind != "cancel" {
t.Fatal("cancel not queued")
}
must(s.AckOnecCommand(ctx, commands[0].ID, true, ""))
assertFree(1)
_, _, known, err := s.SetOnecAppointmentStatus(ctx, uuid, "booked")
must(err)
if known {
t.Fatal("delayed booked hook resurrected cancellation")
}
assertFree(1)
must(s.AcceptOnecSlots(ctx, from, to, []onec.Slot{}))
assertFree(0) // explicit empty horizon clears old shifts
}

View File

@ -10,6 +10,27 @@
color: rgba(44, 73, 95, 0.35); color: rgba(44, 73, 95, 0.35);
} }
.rating-badge {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5.2px;
height: 29px;
padding: 5.9px 8.2px 5.9px 7.4px;
background: var(--soft);
border: 0.744px solid var(--chip-border);
border-radius: 8.923px;
font-weight: 700;
font-size: 13.385px;
color: var(--navy);
}
.rating-badge img {
width: 13.385px;
height: 13.385px;
padding: 0 0.33px 1.28px;
}
/* ---------- плитка «Специалисты» на главной: 209 × 232 ---------- */ /* ---------- плитка «Специалисты» на главной: 209 × 232 ---------- */
.doctor-tile { .doctor-tile {
width: 209px; width: 209px;
@ -35,6 +56,12 @@
object-position: center top; object-position: center top;
} }
.doctor-tile__rating {
position: absolute;
top: 8px;
right: 9px;
}
.doctor-tile__info { .doctor-tile__info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -192,6 +219,22 @@
height: 180px; height: 180px;
border-radius: 0; border-radius: 0;
} }
.doctor-tile__rating {
top: 8px;
right: 8px;
height: auto;
padding: 2px 8px;
gap: 4px;
border: 0;
border-radius: var(--r-8);
font-size: 10px;
line-height: 15px;
}
.doctor-tile__rating img {
width: 10px;
height: 10px;
padding: 0;
}
.doctor-tile__info { .doctor-tile__info {
gap: 0; gap: 0;
margin-top: 0; margin-top: 0;

View File

@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { experienceLabel } from '../lib/format.js'; import starIcon from '../assets/figma/icon-star.svg';
import { experienceLabel, ratingLabel } from '../lib/format.js';
import { doctorPath } from '../lib/links.js'; import { doctorPath } from '../lib/links.js';
import { useLeadModal } from '../context/LeadModalContext.jsx'; import { useLeadModal } from '../context/LeadModalContext.jsx';
import './DoctorCard.css'; import './DoctorCard.css';
@ -40,6 +41,16 @@ function DoctorPhoto({ doctor, className }) {
); );
} }
export function RatingBadge({ rating, className = '' }) {
if (typeof rating !== 'number') return null;
return (
<span className={`rating-badge ${className}`} aria-label={`Рейтинг ${ratingLabel(rating)}`}>
<img src={starIcon} alt="" width="13" height="13" />
{ratingLabel(rating)}
</span>
);
}
// Плитка для слайдера «Специалисты» на главной. // Плитка для слайдера «Специалисты» на главной.
export function DoctorTile({ doctor }) { export function DoctorTile({ doctor }) {
const path = doctorPath(doctor); const path = doctorPath(doctor);
@ -47,6 +58,7 @@ export function DoctorTile({ doctor }) {
<article className="doctor-tile"> <article className="doctor-tile">
<Link to={path} className="doctor-tile__photo-link" tabIndex={-1} aria-hidden="true"> <Link to={path} className="doctor-tile__photo-link" tabIndex={-1} aria-hidden="true">
<DoctorPhoto doctor={doctor} className="doctor-tile__photo" /> <DoctorPhoto doctor={doctor} className="doctor-tile__photo" />
<RatingBadge rating={doctor.rating} className="doctor-tile__rating" />
</Link> </Link>
<div className="doctor-tile__info"> <div className="doctor-tile__info">
<Link to={path} className="doctor-tile__name"> <Link to={path} className="doctor-tile__name">
@ -78,6 +90,7 @@ export function DoctorRow({ doctor }) {
{doctor.experience_years != null && ( {doctor.experience_years != null && (
<span className="doctor-row__exp">{experienceLabel(doctor.experience_years)}</span> <span className="doctor-row__exp">{experienceLabel(doctor.experience_years)}</span>
)} )}
<RatingBadge rating={doctor.rating} />
</div> </div>
<div className="doctor-row__actions"> <div className="doctor-row__actions">
{doctor.absent ? ( {doctor.absent ? (

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { BookButton } from '../components/DoctorCard.jsx'; import { BookButton, RatingBadge } from '../components/DoctorCard.jsx';
import { NextSlots } from '../components/NativeBooking.jsx'; import { NextSlots } from '../components/NativeBooking.jsx';
import ContactsSection from '../components/ContactsSection.jsx'; import ContactsSection from '../components/ContactsSection.jsx';
import StubPage from './StubPage.jsx'; import StubPage from './StubPage.jsx';
@ -177,6 +177,7 @@ export default function DoctorPage() {
{d.experience_years != null && ( {d.experience_years != null && (
<span className="doctor-hero__exp">{experienceLabel(d.experience_years)}</span> <span className="doctor-hero__exp">{experienceLabel(d.experience_years)}</span>
)} )}
<RatingBadge rating={d.rating} />
</div> </div>
{d.branches?.length > 0 && ( {d.branches?.length > 0 && (
<p className="doctor-hero__branches"> <p className="doctor-hero__branches">