diff --git a/AGENTS.md b/AGENTS.md index 3710d43..d3ed4c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` и подтверждает успешность выкладки. diff --git a/cmd/server/main.go b/cmd/server/main.go index d76512d..aeb8d10 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/deploy.sh b/deploy.sh index 2f7c4e1..4fa2a05 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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!" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 23b8389..c1015a3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: diff --git a/docs/onec-autonomous.md b/docs/onec-autonomous.md new file mode 100644 index 0000000..9673e61 --- /dev/null +++ b/docs/onec-autonomous.md @@ -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 запускается вручную только если возвращен прежний режим. diff --git a/internal/api/adminui/onec.js b/internal/api/adminui/onec.js index 6f277e4..3f6df52 100644 --- a/internal/api/adminui/onec.js +++ b/internal/api/adminui/onec.js @@ -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: ['', 'Заявка в Битриксе'], }; diff --git a/internal/api/api.go b/internal/api/api.go index 9ff81f0..155c67f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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)) diff --git a/internal/api/booking_confirm.go b/internal/api/booking_confirm.go index af1cfc0..d97ca33 100644 --- a/internal/api/booking_confirm.go +++ b/internal/api/booking_confirm.go @@ -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) } } diff --git a/internal/api/onec.go b/internal/api/onec.go index 88b38d8..11d0b9d 100644 --- a/internal/api/onec.go +++ b/internal/api/onec.go @@ -1,679 +1,448 @@ -package api - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "net/http" - "regexp" - "strconv" - "strings" - "sync" - "time" - "unicode/utf8" - - "dana/internal/domain" - "dana/internal/onec" - "dana/internal/storage" -) - -// Расписание врачей и запись на приём из 1С: окна забираются по таймеру и хранятся у нас, -// перед записью окно перепроверяется в 1С. Если 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() - } - 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С недоступна, -// и фронт остаётся на обычной заявке (у врача не включена запись, 1С не подключена или давно не отвечала). -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() { - off() - return - } - onecID, _, err := a.store.OnecDoctorBySlug(r.Context(), r.PathValue("slug")) - if err != nil { - handleErr(w, err) - return - } - if onecID == "" || !a.store.OnecFresh(r.Context(), onecStaleAfter) { - off() - return - } - from, to := onecWindow() - slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, to) - if err != nil { - handleErr(w, err) - return - } - services, err := a.store.OnecServices(r.Context(), onecID) - if err != nil { - handleErr(w, err) - return - } - ok(w, map[string]any{"enabled": true, "slots": slots, "services": services}, map[string]any{"total": len(slots)}) -} - -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"` - 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"` -} - -var ( - requestIDRE = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) - birthDateRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) -) - -// createAppointment записывает пациента в 1С и заводит заявку в «Заявках». Сбой 1С (кроме «окно занято») -// не теряет обращение: заявка сохраняется обычной, администратор перезвонит. -func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) { - if a.tooMany(w, "appt:"+clientIP(r), 10, 10*time.Minute) { - return - } - var in appointmentInput - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxAppointmentIn)).Decode(&in); err != nil { - fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать запрос") - return - } - in.Name = strings.TrimSpace(in.Name) - in.Phone = strings.TrimSpace(in.Phone) - in.Comment = strings.TrimSpace(in.Comment) - for _, v := range []string{in.Name, in.Phone, in.Comment} { - if strings.ContainsRune(v, utf8.RuneError) { - fail(w, http.StatusBadRequest, "bad_encoding", "Текст пришёл в неверной кодировке — отправьте форму ещё раз") - return - } - } - if !in.Consent { - fail(w, http.StatusBadRequest, "consent_required", "Нужно согласие на обработку персональных данных") - return - } - if in.Name == "" { - fail(w, http.StatusBadRequest, "name_required", "Укажите имя") - return - } - if len(digitsRE.ReplaceAllString(in.Phone, "")) < 10 { - fail(w, http.StatusBadRequest, "phone_invalid", "Укажите корректный телефон") - return - } - - if !requestIDRE.MatchString(in.RequestID) { - fail(w, http.StatusBadRequest, "request_id_invalid", "Не удалось отправить запись, обновите страницу") - return - } - if in.BirthDate != "" && !birthDateRE.MatchString(in.BirthDate) { - fail(w, http.StatusBadRequest, "birth_invalid", "Дата рождения указана неверно") - return - } - start, err := time.Parse(time.RFC3339, in.Start) - if err != nil || start.Before(time.Now()) || start.After(time.Now().AddDate(0, 0, onecHorizonDays+2)) { - fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз") - return - } - if !a.onec.Configured() { - fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку") - return - } - ctx := r.Context() - onecID, doctorID, err := a.store.OnecDoctorBySlug(ctx, in.DoctorSlug) - if err != nil { - handleErr(w, err) - return - } - if onecID == "" { - fail(w, http.StatusNotFound, "doctor_not_bookable", "К этому врачу онлайн-запись недоступна, оставьте заявку") - return - } - - // Защита расписания от ботов. Обычно запись идёт без звонка, а при подозрительной активности - // (всплеск записей, много неподтверждённых к врачу, много записей на номер) сайт просит - // проверочный звонок: настоящий пациент записывается дальше, бот останавливается. - // Без SMSC подозрительная запись не занимает окно, а уходит оператору заявкой. - cfg, err := a.store.BookingSettings(ctx) - if err != nil { - slog.Warn("booking settings", "err", err) - cfg = storage.DefaultBookingSettings - } - risk, err := a.bookingRiskReason(ctx, cfg, onecID, in.Phone, start) - if err != nil { - slog.Warn("booking limits", "err", err) - } - leadOnly := "" - if cfg.PhoneCheck || risk != "" { - if a.smscLogin != "" { - if in.VerificationID == "" || !wcm.isVerified(in.VerificationID, in.Phone) { - if risk != "" { - slog.Warn("booking: прошу проверочный звонок", "reason", risk) - } - fail(w, http.StatusForbidden, "phone_not_verified", "Подтвердите номер телефона с помощью проверочного звонка") - return - } - } else if risk != "" { - leadOnly = risk - } - } - // не больше 3 записей в час на один номер: проверяем после звонка, чтобы отклонённая попытка - // без звонка не тратила пациенту лимит - if a.tooMany(w, "appt-phone:"+lastDigits(in.Phone, 10), 3, time.Hour) { - return - } - - 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, - }) - if err != nil { - handleErr(w, err) - return - } - if !created { - a.repeatAppointment(w, rec) - return - } - - if leadOnly != "" { - slog.Warn("booking: SMSC не настроен, подозрительная запись станет заявкой", "reason", leadOnly) - a.appointmentFallback(w, r, in, doctorID, rec, fmt.Errorf("%w: %s", errBookingLimit, leadOnly)) - 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), - }) - 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) - writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{ - "mode": "booked", - "start": slot.Start, - "end": end, - "branch": branch, - "message": a.bookedMessage(slot.Start), - }}) -} - -// bookedMessage — что видит пациент после записи. При подтверждении оператором честно говорим, что позвонят. -func (a *API) bookedMessage(start time.Time) string { - if a.bookingConfirmEnabled() { - return "Вы записаны на приём " + formatSlotTime(start) + ". Администратор позвонит вам, чтобы подтвердить запись." - } - return "Вы записаны на приём " + formatSlotTime(start) + ". Ждём вас в клинике!" -} - -// repeatAppointment отвечает на повтор того же запроса (тот же request_id), не создавая записи заново. -func (a *API) repeatAppointment(w http.ResponseWriter, rec storage.OnecAppointment) { - switch rec.Status { - case "booked": - writeJSON(w, http.StatusOK, envelope{Data: map[string]any{ - "mode": "booked", "start": rec.Start, "message": a.bookedMessage(rec.Start), - }}) - case "failed": - writeJSON(w, http.StatusOK, envelope{Data: map[string]any{ - "mode": "lead", "message": "Спасибо! Мы получили заявку и перезвоним, чтобы подтвердить запись.", - }}) - default: - fail(w, http.StatusConflict, "in_progress", "Запись уже оформляется, подождите несколько секунд") - } -} - -// slotTaken — окно заняли: отдаём 409 и свежий список окон, чтобы пациент выбрал другое. -func (a *API) slotTaken(w http.ResponseWriter, r *http.Request, rec storage.OnecAppointment, onecID, reason string) { - _ = a.store.FinishOnecAppointment(context.Background(), rec.ID, "failed", "", "slot_busy: "+reason, nil, nil) - from, to := onecWindow() - slots, _ := a.store.FreeOnecSlots(r.Context(), onecID, from, to) - if slots == nil { - slots = []storage.OnecSlot{} - } - writeJSON(w, http.StatusConflict, map[string]any{ - "error": map[string]any{"code": "slot_busy", "message": "Это время только что заняли. Выберите другое."}, - "slots": slots, - }) -} - -// appointmentFallback — 1С не смогла принять запись: сохраняем обычную заявку и честно говорим об этом администратору. -func (a *API) appointmentFallback(w http.ResponseWriter, r *http.Request, in appointmentInput, doctorID int64, rec storage.OnecAppointment, cause error) { - slog.Warn("onec: запись не прошла, сохраняем заявку", "request_id", rec.RequestID, "err", cause) - start, _ := time.Parse(time.RFC3339, in.Start) - msg := fmt.Sprintf("НЕ ЗАПИСАН В 1С (%s). Пациент выбрал: %s. Перезвоните и запишите вручную.%s", - onecErrText(cause), formatSlotTime(start), a.serviceNote(r.Context(), rec.DoctorOnec, in.ServiceID)) - leadID := a.saveAppointmentLead(r.Context(), r, in, doctorID, msg, map[string]any{"onec_error": cause.Error()}) - _ = a.store.FinishOnecAppointment(context.Background(), rec.ID, "failed", "", cause.Error(), nil, leadID) - // оператор работает в Битрикс24 — передаём заявку туда, чтобы записать пациента вручную - if a.bookingConfirmEnabled() { - if err := a.store.MarkLeadOnly(context.Background(), rec.ID); err != nil { - slog.Error("booking lead-only mark", "id", rec.ID, "err", err) - } - a.kickConfirm() - } - writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{ - "mode": "lead", "message": "Спасибо! Мы получили заявку и перезвоним, чтобы подтвердить запись.", - }}) -} - -func (a *API) saveAppointmentLead(ctx context.Context, r *http.Request, in appointmentInput, doctorID int64, message string, extra map[string]any) *int64 { - var formID *int64 - if f, err := a.store.FormByCode(ctx, "appointment"); err == nil { - formID = &f.ID - } - if in.Comment != "" { - message += "\nКомментарий пациента: " + in.Comment - } - if in.BirthDate != "" { - extra["birth_date"] = in.BirthDate - } - id, err := a.store.CreateLead(ctx, formID, domain.LeadInput{ - Name: in.Name, Phone: in.Phone, DoctorID: &doctorID, Message: message, Extra: extra, - PageURL: in.PageURL, UTM: cleanUTM(in.UTM), Consent: true, - }, clientIP(r), r.UserAgent()) - if err != nil { - slog.Error("onec: заявка не сохранилась", "err", err) - return nil - } - a.notifyLead(id) - return &id -} - -func (a *API) serviceNote(ctx context.Context, onecID, serviceID string) string { - if serviceID == "" { - return "" - } - if n := a.store.OnecServiceName(ctx, onecID, serviceID); n != "" { - return " Услуга: " + n + "." - } - return "" -} - -func formatSlotTime(t time.Time) string { - return t.In(moscow).Format("02.01.2006 в 15:04") -} - -func branchOrDash(s string) string { - if s == "" { - return "филиал не указан" - } - return s -} - -// ---------- Админка ---------- - -func (a *API) adminOnec(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { - st, err := a.store.OnecSyncStatus(r.Context()) - if err != nil { - handleErr(w, err) - return - } - doctors, err := a.store.OnecDoctorsAdmin(r.Context()) - if err != nil { - handleErr(w, err) - return - } - ok(w, map[string]any{ - "configured": a.onec.Configured(), - "host": a.onec.Host(), - "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)) - return - } - a.adminOnec(w, r, u) -} - -func (a *API) adminOnecLink(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { - var in struct { - DoctorID *int64 `json:"doctor_id"` - } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") - return - } - if err := a.store.SetOnecDoctorLink(r.Context(), r.PathValue("onec_id"), in.DoctorID); err != nil { - handleErr(w, err) - return - } - var id int64 - if in.DoctorID != nil { - id = *in.DoctorID - } - a.store.Audit(r.Context(), u.ID, "link", "onec_doctor", id, clientIP(r)) - ok(w, map[string]any{"onec_id": r.PathValue("onec_id"), "doctor_id": in.DoctorID}, nil) -} - -func (a *API) adminOnecBooking(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { - var in struct { - Enabled bool `json:"enabled"` - } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") - return - } - if err := a.store.SetOnecBooking(r.Context(), r.PathValue("onec_id"), in.Enabled); err != nil { - if errors.Is(err, storage.ErrNotFound) { - fail(w, http.StatusUnprocessableEntity, "not_linked", "Сначала привяжите врача к карточке сайта") - return - } - handleErr(w, err) - return - } - a.store.Audit(r.Context(), u.ID, "booking", "onec_doctor", 0, clientIP(r)) - ok(w, map[string]any{"onec_id": r.PathValue("onec_id"), "enabled": in.Enabled}, nil) -} - -func (a *API) adminOnecGrid(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { - day := time.Now().In(moscow) - if s := r.URL.Query().Get("date"); s != "" { - d, err := time.ParseInLocation("2006-01-02", s, moscow) - if err != nil { - fail(w, http.StatusBadRequest, "bad_date", "Дата указана неверно") - return - } - day = d - } - day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, moscow) - rows, err := a.store.OnecGrid(r.Context(), day) - if err != nil { - handleErr(w, err) - return - } - ok(w, rows, map[string]any{"date": day.Format("2006-01-02")}) -} - -func (a *API) adminOnecAppointments(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - if limit <= 0 || limit > 500 { - limit = 100 - } - list, err := a.store.OnecAppointments(r.Context(), limit) - if err != nil { - handleErr(w, err) - return - } - // в списке — имена и телефоны пациентов: просмотр фиксируем в аудите - 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) - } - } - } -} +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "dana/internal/domain" + "dana/internal/onec" + "dana/internal/storage" +) + +// 1С сама отправляет расписание и забирает pending. Сайт не вызывает 1С по сети. + +const ( + onecHorizonDays = 14 + onecStaleAfter = 24 * time.Hour // после этого срока без удачной синхронизации окна не показываем + maxAppointmentIn = 16 << 10 +) + +// WithOneC подключает 1С; без адреса или ключа запись через 1С выключена. +func (a *API) WithOneC(c *onec.Client) { a.onec = c } + +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) +} + +func onecErrText(err error) string { + if errors.Is(err, onec.ErrUnauthorized) { + return "1С не приняла ключ доступа: проверьте ONEC_API_KEY и что IP сервера сайта открыт в 1С. " + err.Error() + } + return err.Error() +} + +// ---------- Публичное API ---------- + +// doctorSlots отдаёт свободные окна врача и услуги приёма. enabled=false — запись через 1С недоступна, +// и фронт остаётся на обычной заявке (у врача не включена запись, 1С не подключена или давно не отвечала). +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.onecAutonomousEnabled() { + off() + return + } + onecID, _, err := a.store.OnecDoctorBySlug(r.Context(), r.PathValue("slug")) + if err != nil { + handleErr(w, err) + return + } + if onecID == "" || !a.store.OnecFresh(r.Context(), onecStaleAfter) { + off() + return + } + from, to := onecWindow() + slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, to) + if err != nil { + handleErr(w, err) + return + } + services, err := a.store.OnecServices(r.Context(), onecID) + if err != nil { + handleErr(w, err) + return + } + ok(w, map[string]any{"enabled": true, "slots": slots, "services": services}, map[string]any{"total": len(slots)}) +} + +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"` + 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"` +} + +var ( + requestIDRE = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) + birthDateRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) +) + +// createAppointment записывает пациента в 1С и заводит заявку в «Заявках». Сбой 1С (кроме «окно занято») +// не теряет обращение: заявка сохраняется обычной, администратор перезвонит. +func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) { + if a.tooMany(w, "appt:"+clientIP(r), 10, 10*time.Minute) { + return + } + var in appointmentInput + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxAppointmentIn)).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать запрос") + return + } + in.Name = strings.TrimSpace(in.Name) + in.Phone = strings.TrimSpace(in.Phone) + in.Comment = strings.TrimSpace(in.Comment) + for _, v := range []string{in.Name, in.Phone, in.Comment} { + if strings.ContainsRune(v, utf8.RuneError) { + fail(w, http.StatusBadRequest, "bad_encoding", "Текст пришёл в неверной кодировке — отправьте форму ещё раз") + return + } + } + if !in.Consent { + fail(w, http.StatusBadRequest, "consent_required", "Нужно согласие на обработку персональных данных") + return + } + if in.Name == "" { + fail(w, http.StatusBadRequest, "name_required", "Укажите имя") + return + } + if len(digitsRE.ReplaceAllString(in.Phone, "")) < 10 { + fail(w, http.StatusBadRequest, "phone_invalid", "Укажите корректный телефон") + return + } + + if !requestIDRE.MatchString(in.RequestID) { + fail(w, http.StatusBadRequest, "request_id_invalid", "Не удалось отправить запись, обновите страницу") + return + } + if in.BirthDate != "" && !birthDateRE.MatchString(in.BirthDate) { + fail(w, http.StatusBadRequest, "birth_invalid", "Дата рождения указана неверно") + return + } + start, err := time.Parse(time.RFC3339, in.Start) + if err != nil || start.Before(time.Now()) || start.After(time.Now().AddDate(0, 0, onecHorizonDays+2)) { + fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз") + return + } + 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) + return + } + if onecID == "" { + fail(w, http.StatusNotFound, "doctor_not_bookable", "К этому врачу онлайн-запись недоступна, оставьте заявку") + return + } + + // Защита расписания от ботов. Обычно запись идёт без звонка, а при подозрительной активности + // (всплеск записей, много неподтверждённых к врачу, много записей на номер) сайт просит + // проверочный звонок: настоящий пациент записывается дальше, бот останавливается. + // Без SMSC подозрительная запись не занимает окно, а уходит оператору заявкой. + cfg, err := a.store.BookingSettings(ctx) + if err != nil { + slog.Warn("booking settings", "err", err) + cfg = storage.DefaultBookingSettings + } + risk, err := a.bookingRiskReason(ctx, cfg, onecID, in.Phone, start) + if err != nil { + slog.Warn("booking limits", "err", err) + } + leadOnly := "" + if cfg.PhoneCheck || risk != "" { + if a.smscLogin != "" { + if in.VerificationID == "" || !wcm.isVerified(in.VerificationID, in.Phone) { + if risk != "" { + slog.Warn("booking: прошу проверочный звонок", "reason", risk) + } + fail(w, http.StatusForbidden, "phone_not_verified", "Подтвердите номер телефона с помощью проверочного звонка") + return + } + } else if risk != "" { + leadOnly = risk + } + } + // не больше 3 записей в час на один номер: проверяем после звонка, чтобы отклонённая попытка + // без звонка не тратила пациенту лимит + if a.tooMany(w, "appt-phone:"+lastDigits(in.Phone, 10), 3, time.Hour) { + 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, 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 + } + if !created { + a.repeatAppointment(w, rec) + return + } + + if leadOnly != "" { + slog.Warn("booking: SMSC не настроен, подозрительная запись станет заявкой", "reason", leadOnly) + a.appointmentFallback(w, r, in, doctorID, rec, fmt.Errorf("%w: %s", errBookingLimit, leadOnly)) + return + } + + 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 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": "pending", + "start": rec.Start, + "end": rec.End, + "branch": branch, + "message": "Заявка принята. Подтверждение записи ожидается от клиники.", + }}) +} + +// bookedMessage — что видит пациент после записи. При подтверждении оператором честно говорим, что позвонят. +func (a *API) bookedMessage(start time.Time) string { + if a.bookingConfirmEnabled() { + return "Вы записаны на приём " + formatSlotTime(start) + ". Администратор позвонит вам, чтобы подтвердить запись." + } + return "Вы записаны на приём " + formatSlotTime(start) + ". Ждём вас в клинике!" +} + +// 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), + }}) + case "failed": + writeJSON(w, http.StatusOK, envelope{Data: map[string]any{ + "mode": "lead", "message": "Спасибо! Мы получили заявку и перезвоним, чтобы подтвердить запись.", + }}) + default: + fail(w, http.StatusConflict, "in_progress", "Запись уже оформляется, подождите несколько секунд") + } +} + +// slotTaken — окно заняли: отдаём 409 и свежий список окон, чтобы пациент выбрал другое. +func (a *API) slotTaken(w http.ResponseWriter, r *http.Request, rec storage.OnecAppointment, onecID, reason string) { + _ = a.store.FinishOnecAppointment(context.Background(), rec.ID, "failed", "", "slot_busy: "+reason, nil, nil) + from, to := onecWindow() + slots, _ := a.store.FreeOnecSlots(r.Context(), onecID, from, to) + if slots == nil { + slots = []storage.OnecSlot{} + } + writeJSON(w, http.StatusConflict, map[string]any{ + "error": map[string]any{"code": "slot_busy", "message": "Это время только что заняли. Выберите другое."}, + "slots": slots, + }) +} + +// appointmentFallback — 1С не смогла принять запись: сохраняем обычную заявку и честно говорим об этом администратору. +func (a *API) appointmentFallback(w http.ResponseWriter, r *http.Request, in appointmentInput, doctorID int64, rec storage.OnecAppointment, cause error) { + slog.Warn("onec: запись не прошла, сохраняем заявку", "request_id", rec.RequestID, "err", cause) + start, _ := time.Parse(time.RFC3339, in.Start) + msg := fmt.Sprintf("НЕ ЗАПИСАН В 1С (%s). Пациент выбрал: %s. Перезвоните и запишите вручную.%s", + onecErrText(cause), formatSlotTime(start), a.serviceNote(r.Context(), rec.DoctorOnec, in.ServiceID)) + leadID := a.saveAppointmentLead(r.Context(), r, in, doctorID, msg, map[string]any{"onec_error": cause.Error()}) + _ = a.store.FinishOnecAppointment(context.Background(), rec.ID, "failed", "", cause.Error(), nil, leadID) + // оператор работает в Битрикс24 — передаём заявку туда, чтобы записать пациента вручную + if a.bookingConfirmEnabled() { + if err := a.store.MarkLeadOnly(context.Background(), rec.ID); err != nil { + slog.Error("booking lead-only mark", "id", rec.ID, "err", err) + } + a.kickConfirm() + } + writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{ + "mode": "lead", "message": "Спасибо! Мы получили заявку и перезвоним, чтобы подтвердить запись.", + }}) +} + +func (a *API) saveAppointmentLead(ctx context.Context, r *http.Request, in appointmentInput, doctorID int64, message string, extra map[string]any) *int64 { + var formID *int64 + if f, err := a.store.FormByCode(ctx, "appointment"); err == nil { + formID = &f.ID + } + if in.Comment != "" { + message += "\nКомментарий пациента: " + in.Comment + } + if in.BirthDate != "" { + extra["birth_date"] = in.BirthDate + } + id, err := a.store.CreateLead(ctx, formID, domain.LeadInput{ + Name: in.Name, Phone: in.Phone, DoctorID: &doctorID, Message: message, Extra: extra, + PageURL: in.PageURL, UTM: cleanUTM(in.UTM), Consent: true, + }, clientIP(r), r.UserAgent()) + if err != nil { + slog.Error("onec: заявка не сохранилась", "err", err) + return nil + } + a.notifyLead(id) + return &id +} + +func (a *API) serviceNote(ctx context.Context, onecID, serviceID string) string { + if serviceID == "" { + return "" + } + if n := a.store.OnecServiceName(ctx, onecID, serviceID); n != "" { + return " Услуга: " + n + "." + } + return "" +} + +func formatSlotTime(t time.Time) string { + return t.In(moscow).Format("02.01.2006 в 15:04") +} + +func branchOrDash(s string) string { + if s == "" { + return "филиал не указан" + } + return s +} + +// ---------- Админка ---------- + +func (a *API) adminOnec(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + st, err := a.store.OnecSyncStatus(r.Context()) + if err != nil { + handleErr(w, err) + return + } + doctors, err := a.store.OnecDoctorsAdmin(r.Context()) + if err != nil { + handleErr(w, err) + return + } + ok(w, map[string]any{ + "configured": a.onecAutonomousEnabled(), + "host": "Push/Pull", + "status": st, + "doctors": doctors, + }, nil) +} + +func (a *API) adminOnecSync(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + if err := a.store.RequestOnecSnapshot(r.Context()); err != nil { + handleErr(w, err) + return + } + a.adminOnec(w, r, u) +} + +func (a *API) adminOnecLink(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + var in struct { + DoctorID *int64 `json:"doctor_id"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + if err := a.store.SetOnecDoctorLink(r.Context(), r.PathValue("onec_id"), in.DoctorID); err != nil { + handleErr(w, err) + return + } + var id int64 + if in.DoctorID != nil { + id = *in.DoctorID + } + a.store.Audit(r.Context(), u.ID, "link", "onec_doctor", id, clientIP(r)) + ok(w, map[string]any{"onec_id": r.PathValue("onec_id"), "doctor_id": in.DoctorID}, nil) +} + +func (a *API) adminOnecBooking(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + var in struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") + return + } + if err := a.store.SetOnecBooking(r.Context(), r.PathValue("onec_id"), in.Enabled); err != nil { + if errors.Is(err, storage.ErrNotFound) { + fail(w, http.StatusUnprocessableEntity, "not_linked", "Сначала привяжите врача к карточке сайта") + return + } + handleErr(w, err) + return + } + a.store.Audit(r.Context(), u.ID, "booking", "onec_doctor", 0, clientIP(r)) + ok(w, map[string]any{"onec_id": r.PathValue("onec_id"), "enabled": in.Enabled}, nil) +} + +func (a *API) adminOnecGrid(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + day := time.Now().In(moscow) + if s := r.URL.Query().Get("date"); s != "" { + d, err := time.ParseInLocation("2006-01-02", s, moscow) + if err != nil { + fail(w, http.StatusBadRequest, "bad_date", "Дата указана неверно") + return + } + day = d + } + day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, moscow) + rows, err := a.store.OnecGrid(r.Context(), day) + if err != nil { + handleErr(w, err) + return + } + ok(w, rows, map[string]any{"date": day.Format("2006-01-02")}) +} + +func (a *API) adminOnecAppointments(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 || limit > 500 { + limit = 100 + } + list, err := a.store.OnecAppointments(r.Context(), limit) + if err != nil { + handleErr(w, err) + return + } + // в списке — имена и телефоны пациентов: просмотр фиксируем в аудите + a.store.Audit(r.Context(), u.ID, "view", "onec_appointments", 0, clientIP(r)) + ok(w, list, map[string]any{"total": len(list)}) +} diff --git a/internal/api/onec_autonomous_test.go b/internal/api/onec_autonomous_test.go new file mode 100644 index 0000000..436d6d8 --- /dev/null +++ b/internal/api/onec_autonomous_test.go @@ -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") + } +} diff --git a/internal/api/onec_push.go b/internal/api/onec_push.go index a5e4dd1..bc5b399 100644 --- a/internal/api/onec_push.go +++ b/internal/api/onec_push.go @@ -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 +} diff --git a/internal/api/onec_push_test.go b/internal/api/onec_push_test.go index e10a42f..be0a3b2 100644 --- a/internal/api/onec_push_test.go +++ b/internal/api/onec_push_test.go @@ -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) + } + } +} diff --git a/internal/api/onec_webhook.go b/internal/api/onec_webhook.go index cd91993..b005fec 100644 --- a/internal/api/onec_webhook.go +++ b/internal/api/onec_webhook.go @@ -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) - } - }() -} diff --git a/internal/api/onec_webhook_test.go b/internal/api/onec_webhook_test.go index 3d10117..122a345 100644 --- a/internal/api/onec_webhook_test.go +++ b/internal/api/onec_webhook_test.go @@ -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) + } +} diff --git a/internal/storage/migrations/0027_onec_autonomous.sql b/internal/storage/migrations/0027_onec_autonomous.sql new file mode 100644 index 0000000..35bc85b --- /dev/null +++ b/internal/storage/migrations/0027_onec_autonomous.sql @@ -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) +); diff --git a/internal/storage/onec.go b/internal/storage/onec.go index 21994fa..f322132 100644 --- a/internal/storage/onec.go +++ b/internal/storage/onec.go @@ -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 } diff --git a/internal/storage/onec_autonomous.go b/internal/storage/onec_autonomous.go new file mode 100644 index 0000000..d1a727a --- /dev/null +++ b/internal/storage/onec_autonomous.go @@ -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 +} diff --git a/internal/storage/onec_autonomous_test.go b/internal/storage/onec_autonomous_test.go new file mode 100644 index 0000000..e300050 --- /dev/null +++ b/internal/storage/onec_autonomous_test.go @@ -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 +}