Compare commits

..

4 Commits

Author SHA1 Message Date
Abdulkadyr Karagishiev
7ac18ec61d fix(onec): preserve patient instructions in Bitrix leads
Some checks failed
Deploy Dana Medical Site / deploy (push) Failing after 1s
2026-10-21 14:07:05 +03:00
Abdulkadyr Karagishiev
25ddf86ac4 merge: preserve deployed doctor card changes 2026-10-21 12:51:29 +03:00
Abdulkadyr Karagishiev
b126f2c52d fix(onec): complete outbound push pull and protect booked intervals 2026-10-21 12:47:15 +03:00
Dana Medical Developer
70b3e6b260 fix(web): remove public doctor rating badges 2026-10-21 07:56:31 +00:00
22 changed files with 1285 additions and 958 deletions

View File

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

View File

@ -80,10 +80,8 @@ func run() error {
handler.WithClinicPBX("ochevidno", megafon.New(cfg.OchevidnoMegafonURL, cfg.OchevidnoMegafonKey))
}
go handler.RunPBXSync(ctx)
handler.WithOneC(onec.New(cfg.OnecURL, cfg.OnecKey, cfg.OnecAuthHeader))
handler.WithOneC(onec.New("", cfg.OnecKey, cfg.OnecAuthHeader))
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.WithCRM(crm.New(cfg.CrmAPIURL, cfg.CrmLeadIntakeSecret), cfg.CrmMode)
go handler.RunBookingConfirm(ctx)

View File

@ -9,22 +9,28 @@ cd "$DEPLOY_DIR"
# 1. Pull latest code from Gitea
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 reset --hard origin/main
git merge --ff-only origin/main
# 2. Build and restart API container
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 up -d api
docker compose -p dana-medical -f docker-compose.prod.yml up -d --no-deps api
# 3. Check health status
echo "Verifying health..."
api_id="$(docker compose -p dana-medical -f docker-compose.prod.yml ps -q api)"
test -n "$api_id"
healthy=false
for i in $(seq 1 30); do
status="$(docker inspect --format '{{.State.Health.Status}}' "$api_id" 2>/dev/null || echo "starting")"
if [ "$status" = "healthy" ]; then
healthy=true
echo "API service is healthy!"
break
fi
@ -36,4 +42,16 @@ for i in $(seq 1 30); do
sleep 2
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!"

View File

@ -29,6 +29,9 @@ services:
NODE_BINARY: node
SITE_URL: ${SITE_URL:?SITE_URL is required}
DEBUG: "false"
ONEC_API_URL: ""
ONEC_MCP_URL: ""
ONEC_MCP_TOKEN: ""
# Caddy уже работает в контейнере Ability Center. Публикуем DANA только
# на docker0 хоста: из интернета порт недоступен, Caddy обращается к нему
# как к 172.17.0.1:8082.
@ -48,46 +51,6 @@ services:
security_opt:
- 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:
db-data:

35
docs/onec-autonomous.md Normal file
View File

@ -0,0 +1,35 @@
# Автономная интеграция 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 CONFIRM = {
waiting: ['wait', 'Ждёт оператора'], confirmed: ['', 'Подтверждена'], cancelled: ['grey', 'Отменена оператором'],
waiting: ['wait', 'Ждёт оператора'], cancel_pending: ['wait', 'Ожидает отмены в 1С'], confirmed: ['', 'Подтверждена'], cancelled: ['grey', 'Отменена оператором'],
expired: ['off', 'Не подтвердили — отменена'], missed: ['grey', 'Приём без подтверждения'],
lead_only: ['wait', 'Отправляется в Битрикс'], lead_sent: ['', 'Заявка в Битриксе'],
};

View File

@ -7,7 +7,6 @@ import (
"net/http"
"reflect"
"strings"
"time"
"dana/internal/bitrix"
"dana/internal/crm"
@ -28,8 +27,6 @@ type API struct {
pbxClinics map[string]*megafon.Client // АТС других клиник (clinics.go)
onec *onec.Client
onecHook string
onecEvery time.Duration
onecLongPoll bool
bitrix *bitrix.Client
crm *crm.Client
crmMode string
@ -141,6 +138,7 @@ func (a *API) Routes() http.Handler {
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}/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("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))

View File

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

View File

@ -10,7 +10,6 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
@ -19,98 +18,23 @@ import (
"dana/internal/storage"
)
// Расписание врачей и запись на приём из 1С: окна забираются по таймеру и хранятся у нас,
// перед записью окно перепроверяется в 1С. Если 1С не отвечает, сайт не падает, а принимает обычную заявку.
// 1С сама отправляет расписание и забирает pending. Сайт не вызывает 1С по сети.
const (
onecHorizonDays = 14
onecSyncEvery = 5 * time.Minute
onecDirEvery = time.Hour
onecStaleAfter = 24 * time.Hour // после этого срока без удачной синхронизации окна не показываем
onecSourceLabel = "Сайт"
maxAppointmentIn = 16 << 10
)
// WithOneC подключает 1С; без адреса или ключа запись через 1С выключена.
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) {
now := time.Now().In(moscow)
from = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, moscow)
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 {
if errors.Is(err, onec.ErrUnauthorized) {
return "1С не приняла ключ доступа: проверьте ONEC_API_KEY и что IP сервера сайта открыт в 1С. " + err.Error()
@ -118,45 +42,6 @@ func onecErrText(err error) string {
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 ----------
// doctorSlots отдаёт свободные окна врача и услуги приёма. enabled=false — запись через 1С недоступна,
@ -164,7 +49,7 @@ func (a *API) RunOneCSync(ctx context.Context) {
func (a *API) doctorSlots(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
off := func() { ok(w, map[string]any{"enabled": false, "slots": []any{}, "services": []any{}}, nil) }
if !a.onec.Configured() {
if !a.onecAutonomousEnabled() {
off()
return
}
@ -192,19 +77,19 @@ func (a *API) doctorSlots(w http.ResponseWriter, r *http.Request) {
}
type appointmentInput struct {
DoctorSlug string `json:"doctor_slug"`
Start string `json:"start"`
BranchID string `json:"branch_id"`
ServiceID string `json:"service_id"`
Name string `json:"name"`
DoctorSlug string `json:"doctor_slug"`
Start string `json:"start"`
BranchID string `json:"branch_id"`
ServiceID string `json:"service_id"`
Name string `json:"name"`
Phone string `json:"phone"`
VerificationID string `json:"verification_id"`
BirthDate string `json:"birth_date"`
Comment string `json:"comment"`
Consent bool `json:"consent"`
RequestID string `json:"request_id"`
PageURL string `json:"page_url"`
UTM map[string]string `json:"utm"`
BirthDate string `json:"birth_date"`
Comment string `json:"comment"`
Consent bool `json:"consent"`
RequestID string `json:"request_id"`
PageURL string `json:"page_url"`
UTM map[string]string `json:"utm"`
}
var (
@ -258,11 +143,15 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз")
return
}
if !a.onec.Configured() {
if !a.onecAutonomousEnabled() {
fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку")
return
}
ctx := r.Context()
if !a.store.OnecFresh(ctx, onecStaleAfter) {
fail(w, 503, "onec_stale", "Расписание временно недоступно")
return
}
onecID, doctorID, err := a.store.OnecDoctorBySlug(ctx, in.DoctorSlug)
if err != nil {
handleErr(w, err)
@ -306,10 +195,18 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
return
}
initialStatus := "pending"
if leadOnly != "" {
initialStatus = "failed"
}
rec, created, err := a.store.BeginOnecAppointment(ctx, storage.OnecAppointment{
RequestID: in.RequestID, DoctorOnec: onecID, BranchID: in.BranchID, ServiceID: in.ServiceID,
Start: start, Patient: in.Name, Phone: in.Phone,
Start: start, Patient: in.Name, Phone: in.Phone, Comment: in.Comment, BirthDate: in.BirthDate, Status: initialStatus,
})
if errors.Is(err, storage.ErrOnecSlotBusy) {
fail(w, http.StatusConflict, "slot_busy", "Окно уже занято; выберите другое время")
return
}
if err != nil {
handleErr(w, err)
return
@ -325,108 +222,21 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
return
}
var slot *onec.Slot
var end time.Time
var apptID string
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,
})
switch {
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),
branch := a.store.OnecBranchName(ctx, rec.BranchID)
leadID := a.saveAppointmentLead(ctx, r, in, doctorID, fmt.Sprintf("Ожидает регистрации в 1С: %s, %s.%s",
formatSlotTime(rec.Start), branchOrDash(branch), a.serviceNote(ctx, onecID, in.ServiceID)), map[string]any{
"onec_doctor_id": onecID, "slot_start": rec.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()
}
if err := a.store.AttachOnecLead(ctx, rec.ID, leadID); err != nil {
slog.Error("attach pending appointment lead", "id", rec.ID, "err", err)
}
wcm.consume(in.VerificationID)
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{
"mode": "booked",
"start": slot.Start,
"end": end,
"mode": "pending",
"start": rec.Start,
"end": rec.End,
"branch": branch,
"message": a.bookedMessage(slot.Start),
"message": "Заявка принята. Подтверждение записи ожидается от клиники.",
}})
}
@ -441,6 +251,8 @@ func (a *API) bookedMessage(start time.Time) string {
// repeatAppointment отвечает на повтор того же запроса (тот же request_id), не создавая записи заново.
func (a *API) repeatAppointment(w http.ResponseWriter, rec storage.OnecAppointment) {
switch rec.Status {
case "pending":
writeJSON(w, http.StatusAccepted, envelope{Data: map[string]any{"mode": "pending", "start": rec.Start, "message": "Заявка принята. Ожидается подтверждение клиники."}})
case "booked":
writeJSON(w, http.StatusOK, envelope{Data: map[string]any{
"mode": "booked", "start": rec.Start, "message": a.bookedMessage(rec.Start),
@ -546,18 +358,16 @@ func (a *API) adminOnec(w http.ResponseWriter, r *http.Request, u *storage.Admin
return
}
ok(w, map[string]any{
"configured": a.onec.Configured(),
"host": a.onec.Host(),
"configured": a.onecAutonomousEnabled(),
"host": "Push/Pull",
"status": st,
"doctors": doctors,
}, nil)
}
func (a *API) adminOnecSync(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
if err := a.syncOneC(ctx, true); err != nil {
fail(w, http.StatusBadGateway, "onec_sync", onecErrText(err))
if err := a.store.RequestOnecSnapshot(r.Context()); err != nil {
handleErr(w, err)
return
}
a.adminOnec(w, r, u)
@ -636,44 +446,3 @@ 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))
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

@ -0,0 +1,142 @@
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,8 +1,11 @@
package api
import (
"dana/internal/storage"
"encoding/json"
"errors"
"net/http"
"regexp"
"strconv"
"strings"
"time"
@ -29,7 +32,11 @@ func (a *API) checkOnecKey(r *http.Request) bool {
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 {
From *time.Time `json:"from"`
To *time.Time `json:"to"`
Branches []onec.Branch `json:"branches"`
Doctors []onec.Doctor `json:"doctors"`
Services []onecDoctorServices `json:"services"`
@ -54,6 +61,28 @@ func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) {
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()
linked := 0
var err error
@ -69,7 +98,7 @@ func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) {
// 2. Сохраняем услуги врачей
for _, ds := range payload.Services {
if ds.DoctorID != "" && len(ds.Services) > 0 {
if ds.DoctorID != "" {
if err := a.store.SaveOnecServices(ctx, ds.DoctorID, ds.Services); err != nil {
fail(w, http.StatusInternalServerError, "db_error", "Ошибка сохранения услуг: "+err.Error())
return
@ -77,13 +106,13 @@ func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) {
}
}
// 3. Сохраняем слоты расписания
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
}
if err := a.store.AcceptOnecSlots(ctx, from, to, payload.Slots); err != nil {
handleErr(w, err)
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{
@ -127,7 +156,18 @@ func (a *API) onecPendingAppointments(w http.ResponseWriter, r *http.Request) {
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{
"commands": commands, "sync_requested": requested,
"ok": true,
"appointments": list,
}, nil)
@ -158,13 +198,17 @@ func (a *API) onecConfirmAppointment(w http.ResponseWriter, r *http.Request) {
return
}
if req.OnecAppointmentID == "" {
if !uuidTextRE.MatchString(req.OnecAppointmentID) {
fail(w, http.StatusBadRequest, "missing_param", "onec_appointment_id обязателен")
return
}
if err := a.store.ConfirmPendingAppointment(r.Context(), id, req.OnecAppointmentID, req.End); err != nil {
fail(w, http.StatusInternalServerError, "db_error", err.Error())
if errors.Is(err, storage.ErrOnecTransition) {
fail(w, 409, "state_conflict", "Заявка уже обработана иначе")
return
}
handleErr(w, err)
return
}
@ -200,9 +244,66 @@ func (a *API) onecRejectAppointment(w http.ResponseWriter, r *http.Request) {
}
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())
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)
}
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,3 +67,17 @@ func TestOnecConfirmBadID(t *testing.T) {
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,135 +1,81 @@
package api
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"log/slog"
"net/http"
"strings"
"sync/atomic"
"time"
)
// Входящие события от 1С. Опрос раз в 5 минут остаётся основным способом, вебхук только ускоряет обновление:
// 1С сообщает, что расписание врача изменилось или запись сменила статус, а сайт сразу перечитывает данные из 1С.
// Данные из тела события в расписание не пишутся — источник правды всегда ответ API 1С.
const maxOnecEventBody = 8 << 10
// WithOneCWebhook включает приём событий от 1С; пустой ключ — вебхук выключен.
func (a *API) WithOneCWebhook(key string) { a.onecHook = strings.TrimSpace(key) }
var onecEventSyncBusy atomic.Bool
func (a *API) WithOneCWebhook(key string) { a.onecHook = strings.TrimSpace(key) }
func (a *API) onecAutonomousEnabled() bool { return a.onecHook != "" }
type onecEvent struct {
Event string `json:"event"`
DoctorID string `json:"doctor_id"`
AppointmentID string `json:"appointment_id"`
Status string `json:"status"`
Event string `json:"event"`
DoctorID string `json:"doctor_id"`
AppointmentID string `json:"appointment_id"`
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 {
g, w := sha256.Sum256([]byte(got)), sha256.Sum256([]byte(want))
return subtle.ConstantTimeCompare(g[:], w[:]) == 1
}
func (a *API) onecEvents(w http.ResponseWriter, r *http.Request) {
if a.onecHook == "" || !a.onec.Configured() {
fail(w, http.StatusNotFound, "not_found", "Не найдено")
if !a.onecAutonomousEnabled() {
fail(w, 404, "not_found", "Не найдено")
return
}
ip := clientIP(r)
if a.tooMany(w, "onec-ev:"+ip, 300, time.Minute) {
return
}
key := r.Header.Get("X-Api-Key")
if key == "" {
key = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
if !sameKey(key, a.onecHook) {
// неверные ключи считаем отдельно: перебор упирается в лимит
if !a.checkOnecKey(r) {
if a.tooMany(w, "onec-bad:"+ip, 20, 10*time.Minute) {
return
}
fail(w, http.StatusUnauthorized, "unauthorized", "Неверный ключ")
fail(w, 401, "unauthorized", "Неверный ключ")
return
}
var ev onecEvent
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxOnecEventBody)).Decode(&ev); err != nil {
fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать событие")
fail(w, 400, "bad_json", "Не удалось разобрать событие")
return
}
ev.DoctorID = strings.TrimSpace(ev.DoctorID)
switch ev.Event {
case "schedule_changed":
if ev.DoctorID == "" {
a.onecFullResyncAsync(false)
writeJSON(w, http.StatusAccepted, envelope{Data: map[string]any{"ok": true, "queued": "schedule"}})
case "schedule_changed", "directory_changed":
if err := a.store.RequestOnecSnapshot(r.Context()); err != nil {
handleErr(w, err)
return
}
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"}})
writeJSON(w, http.StatusAccepted, envelope{Data: map[string]any{"ok": true, "queued": "push"}})
case "appointment_status":
if ev.AppointmentID == "" || (ev.Status != "booked" && ev.Status != "arrived" && ev.Status != "cancelled") {
fail(w, http.StatusBadRequest, "validation", "Нужны appointment_id и status: booked, arrived или cancelled")
fail(w, 400, "validation", "Нужны appointment_id и status: booked, arrived или cancelled")
return
}
doctorID, _, found, err := a.store.SetOnecAppointmentStatus(r.Context(), ev.AppointmentID, ev.Status)
if (ev.Start == nil) != (ev.End == nil) || (ev.Start != nil && !ev.Start.Before(*ev.End)) {
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 {
handleErr(w, err)
return
}
if !found {
// запись сделана не с сайта — нам она не нужна, но для 1С это не ошибка
ok(w, map[string]any{"ok": true, "known": false}, nil)
if err = a.store.RequestOnecSnapshot(r.Context()); err != nil {
handleErr(w, err)
return
}
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)
ok(w, map[string]any{"ok": true, "known": known}, nil)
default:
fail(w, http.StatusBadRequest, "validation", "Неизвестное событие: допустимы schedule_changed, directory_changed, appointment_status")
fail(w, 400, "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,3 +58,11 @@ func TestOnecEventsBruteForceLimit(t *testing.T) {
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,6 +52,7 @@ func (s *Storage) SetBookingSettings(ctx context.Context, b BookingSettings) err
// ConfirmItem — запись с сайта, ждущая решения оператора, со всем, что нужно для лида в Битриксе и CRM.
type ConfirmItem struct {
Comment string
ID int64
Start time.Time
CreatedAt time.Time
@ -73,7 +74,7 @@ const confirmItemSelect = `
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(sv.name, ''), a.onec_appointment_id, a.bitrix_lead_id, a.error,
a.crm_deal_id, a.crm_confirm_state, a.crm_error
a.crm_deal_id, a.crm_confirm_state, a.crm_error, a.patient_comment
FROM onec_appointments a
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
@ -133,7 +134,7 @@ func (s *Storage) confirmItems(ctx context.Context, query string, limit int) ([]
var c ConfirmItem
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.CrmDealID, &c.CrmConfirmState, &c.CrmError); err != nil {
&c.CrmDealID, &c.CrmConfirmState, &c.CrmError, &c.Comment); err != nil {
return nil, err
}
out = append(out, c)

View File

@ -0,0 +1,23 @@
-- 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,6 +2,7 @@ package storage
import (
"context"
"errors"
"strings"
"time"
@ -10,6 +11,9 @@ import (
"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)
// ---------- Справочники ----------
@ -252,7 +256,10 @@ func (s *Storage) FreeOnecSlots(ctx context.Context, doctorOnecID string, from,
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
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
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 (
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)
if err != nil {
return nil, err
@ -356,8 +363,7 @@ func (s *Storage) OnecSyncStatus(ctx context.Context) (OnecSyncStatus, error) {
// OnecFresh сообщает, была ли успешная синхронизация не раньше чем maxAge назад.
func (s *Storage) OnecFresh(ctx context.Context, maxAge time.Duration) bool {
var ok bool
_ = s.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM onec_sync_log WHERE finished_at > now() - $1::interval AND error = '')`,
maxAge.String()).Scan(&ok)
_ = s.pool.QueryRow(ctx, `SELECT last_snapshot_at > now() - $1::interval FROM onec_push_state WHERE singleton`, maxAge.String()).Scan(&ok)
return ok
}
@ -507,6 +513,8 @@ type OnecAppointment struct {
ServiceName string `json:"service_name"`
ServiceID string `json:"-"`
BranchID string `json:"-"`
Comment string `json:"-"`
BirthDate string `json:"-"`
// подтверждение оператором в Битрикс24 (см. booking_confirm.go)
ConfirmState string `json:"confirm_state"`
BitrixLeadID string `json:"bitrix_lead_id"`
@ -520,19 +528,57 @@ type OnecAppointment struct {
// BeginOnecAppointment создаёт запись в статусе pending. Если такой request_id уже был, возвращает прежнюю запись
// и created=false — так повтор запроса не создаёт вторую запись в 1С.
func (s *Storage) BeginOnecAppointment(ctx context.Context, a OnecAppointment) (rec OnecAppointment, created bool, err error) {
err = s.pool.QueryRow(ctx, `
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 {
tx, err := s.pool.Begin(ctx)
if err != nil {
return a, false, err
}
rec, err = s.onecAppointmentByRequest(ctx, a.RequestID)
return rec, 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)
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) {
@ -585,10 +631,16 @@ func (s *Storage) OnecAppointments(ctx context.Context, limit int) ([]OnecAppoin
// SetOnecAppointmentStatus обновляет статус записи по номеру записи в 1С (вебхук 1С).
// Возвращает врача и время приёма, чтобы пересчитать его окна.
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, `
UPDATE onec_appointments SET status=$2, updated_at=now()
WHERE onec_appointment_id=$1 AND status IN ('booked','arrived','cancelled')
RETURNING doctor_onec_id, start_at`, onecAppointmentID, status).Scan(&doctorOnecID, &start)
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),
start_at=COALESCE($5,start_at),end_at=COALESCE($6,end_at),
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 wrapNotFound(err) == ErrNotFound {
return "", time.Time{}, false, nil
@ -608,6 +660,8 @@ type OnecPendingAppointment struct {
Patient string `json:"patient_name"`
Phone string `json:"patient_phone"`
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) {
@ -615,7 +669,7 @@ func (s *Storage) PendingOnecAppointments(ctx context.Context, limit int) ([]One
limit = 20
}
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
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
FROM onec_appointments
WHERE status = 'pending'
ORDER BY id ASC
@ -627,26 +681,39 @@ func (s *Storage) PendingOnecAppointments(ctx context.Context, limit int) ([]One
out := []OnecPendingAppointment{}
for rows.Next() {
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); err != nil {
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 {
return nil, err
}
a.Start = a.Start.In(moscow)
out = append(out, a)
}
return out, rows.Err()
}
func (s *Storage) ConfirmPendingAppointment(ctx context.Context, id int64, onecID string, end *time.Time) error {
_, err := s.pool.Exec(ctx, `
UPDATE onec_appointments
SET status = 'booked', onec_appointment_id = $2, end_at = $3, updated_at = now()
WHERE id = $1 AND status = 'pending'`, id, onecID, end)
return err
tag, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET status='booked',onec_appointment_id=$2,
end_at=COALESCE($3,end_at),updated_at=now() WHERE id=$1 AND
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)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrOnecTransition
}
return nil
}
func (s *Storage) RejectPendingAppointment(ctx context.Context, id int64, reason string) error {
_, err := s.pool.Exec(ctx, `
UPDATE onec_appointments
SET status = 'failed', error = $2, updated_at = now()
WHERE id = $1 AND status = 'pending'`, id, reason)
tag, err := s.pool.Exec(ctx, `UPDATE onec_appointments SET status='failed',error=$2,updated_at=now()
WHERE id=$1 AND status IN ('pending','failed')`, id, reason)
if err != nil {
return err
}
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
}

View File

@ -0,0 +1,157 @@
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

@ -0,0 +1,149 @@
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,27 +10,6 @@
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 ---------- */
.doctor-tile {
width: 209px;
@ -56,12 +35,6 @@
object-position: center top;
}
.doctor-tile__rating {
position: absolute;
top: 8px;
right: 9px;
}
.doctor-tile__info {
display: flex;
flex-direction: column;
@ -219,22 +192,6 @@
height: 180px;
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 {
gap: 0;
margin-top: 0;

View File

@ -1,6 +1,5 @@
import { Link } from 'react-router-dom';
import starIcon from '../assets/figma/icon-star.svg';
import { experienceLabel, ratingLabel } from '../lib/format.js';
import { experienceLabel } from '../lib/format.js';
import { doctorPath } from '../lib/links.js';
import { useLeadModal } from '../context/LeadModalContext.jsx';
import './DoctorCard.css';
@ -41,16 +40,6 @@ 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 }) {
const path = doctorPath(doctor);
@ -58,7 +47,6 @@ export function DoctorTile({ doctor }) {
<article className="doctor-tile">
<Link to={path} className="doctor-tile__photo-link" tabIndex={-1} aria-hidden="true">
<DoctorPhoto doctor={doctor} className="doctor-tile__photo" />
<RatingBadge rating={doctor.rating} className="doctor-tile__rating" />
</Link>
<div className="doctor-tile__info">
<Link to={path} className="doctor-tile__name">
@ -90,7 +78,6 @@ export function DoctorRow({ doctor }) {
{doctor.experience_years != null && (
<span className="doctor-row__exp">{experienceLabel(doctor.experience_years)}</span>
)}
<RatingBadge rating={doctor.rating} />
</div>
<div className="doctor-row__actions">
{doctor.absent ? (

View File

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