dana-medical-site/internal/api/booking_confirm.go

414 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"html"
"log/slog"
"net/http"
"strings"
"time"
"dana/internal/bitrix"
"dana/internal/onec"
"dana/internal/storage"
)
// Подтверждение онлайн-записи оператором. Запись сразу занимает окно в 1С, сайт заводит лид
// в Битрикс24 и раз в минуту смотрит его стадию:
// - успех («Клиент записался») — запись подтверждена;
// - провал (любая провальная стадия) — отменяем запись в 1С, окно освобождается;
// - решения нет к сроку (24 ч после записи или 2 ч до приёма, что раньше) — отменяем сами
// и переводим лид в «Клиент не записался».
// Работает, когда настроены и 1С, и вебхук Битрикса; иначе запись ведёт себя как раньше.
const (
confirmEvery = time.Minute
// confirmMinGrace — меньше этого оператору на звонок не даём, даже если до приёма совсем мало.
confirmMinGrace = 30 * time.Minute
// bitrixSiteSourceID — источник «Сайт danamedical.ru» в CRM клиники: отчёт по каналам относит его к «Сайту».
bitrixSiteSourceID = "112"
)
type confirmAction int
const (
confirmWait confirmAction = iota // ждём оператора
confirmOK // лид в успешной стадии
confirmCancel // лид в провальной стадии — отменить в 1С
confirmExpire // срок вышел — отменить в 1С самим
confirmMissed // приём уже наступил — ничего не отменяем, просто перестаём ждать
)
// errBookingLimit — подозрительная активность при выключенном SMSC: окно в 1С не занимаем,
// передаём заявку оператору (со SMSC в этом случае просим проверочный звонок).
var errBookingLimit = errors.New("лимит онлайн-записей (защита расписания от ботов)")
// bookingRiskReason — почему запись выглядит подозрительной ("" — всё обычно). Срабатывание не отказ:
// сайт просит проверочный звонок, который настоящий пациент делает за минуту, а бот — нет.
func (a *API) bookingRiskReason(ctx context.Context, cfg storage.BookingSettings, doctorOnecID, phone string, start time.Time) (string, error) {
if cfg.MaxPerHour > 0 {
n, err := a.store.SiteBookingsSince(ctx, time.Now().Add(-time.Hour))
if err != nil {
return "", err
}
if n >= cfg.MaxPerHour {
return fmt.Sprintf("за час %d онлайн-записей при лимите %d", n, cfg.MaxPerHour), nil
}
}
if cfg.MaxPerDoctorDay > 0 && a.bookingConfirmEnabled() {
d := start.In(moscow)
from := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, moscow)
n, err := a.store.WaitingForDoctor(ctx, doctorOnecID, from, from.AddDate(0, 0, 1))
if err != nil {
return "", err
}
if n >= cfg.MaxPerDoctorDay {
return fmt.Sprintf("у врача на %s уже %d неподтверждённых при лимите %d", from.Format("02.01"), n, cfg.MaxPerDoctorDay), nil
}
}
if cfg.MaxActivePerPhone > 0 {
n, err := a.store.ActiveBookingsForPhone(ctx, lastDigits(phone, 10))
if err != nil {
return "", err
}
if n >= cfg.MaxActivePerPhone {
return fmt.Sprintf("на номер уже %d будущих записей при лимите %d", n, cfg.MaxActivePerPhone), nil
}
}
return "", nil
}
func (a *API) bookingConfirmEnabled() bool {
return a.bitrix.Configured() && a.onec.Configured()
}
// kickConfirm будит фоновую проверку сразу после новой записи, чтобы лид появился без ожидания минуты.
func (a *API) kickConfirm() {
select {
case a.confirmKick <- struct{}{}:
default:
}
}
// confirmDeadline — когда отменить неподтверждённую запись. ok=false — автоотмена выключена.
func confirmDeadline(created, start time.Time, cfg storage.BookingSettings) (deadline time.Time, ok bool) {
if cfg.AutoCancelHours <= 0 && cfg.CancelBeforeHours <= 0 {
return time.Time{}, false
}
if cfg.AutoCancelHours > 0 {
deadline = created.Add(time.Duration(cfg.AutoCancelHours) * time.Hour)
}
if cfg.CancelBeforeHours > 0 {
before := start.Add(-time.Duration(cfg.CancelBeforeHours) * time.Hour)
if deadline.IsZero() || before.Before(deadline) {
deadline = before
}
}
if earliest := created.Add(confirmMinGrace); deadline.Before(earliest) {
deadline = earliest
}
return deadline, true
}
// decideConfirm — что делать с записью по стадии лида. semantic пустой — лид ещё в работе или удалён в CRM.
func decideConfirm(semantic string, created, start, now time.Time, cfg storage.BookingSettings) confirmAction {
if semantic == bitrix.SemanticSuccess {
return confirmOK
}
// после начала приёма запись в 1С не трогаем: её судьбу решает регистратура
if !now.Before(start) {
return confirmMissed
}
if semantic == bitrix.SemanticFailure {
return confirmCancel
}
if d, ok := confirmDeadline(created, start, cfg); ok && !now.Before(d) {
return confirmExpire
}
return confirmWait
}
// RunBookingConfirm — фоновый цикл: заводит лиды и следит за их стадиями.
func (a *API) RunBookingConfirm(ctx context.Context) {
if !a.bookingConfirmEnabled() {
slog.Info("booking confirm disabled: нужны и 1С, и BITRIX_WEBHOOK_URL")
return
}
slog.Info("booking confirm enabled: онлайн-записи подтверждает оператор в Битрикс24")
t := time.NewTicker(confirmEvery)
defer t.Stop()
for {
c, cancel := context.WithTimeout(ctx, 2*time.Minute)
if err := a.processConfirmQueue(c, time.Now()); err != nil {
slog.Warn("booking confirm", "err", err)
}
cancel()
select {
case <-ctx.Done():
return
case <-t.C:
case <-a.confirmKick:
}
}
}
func (a *API) processConfirmQueue(ctx context.Context, now time.Time) error {
if err := a.sendLeadOnly(ctx); err != nil {
return err
}
cfg, err := a.store.BookingSettings(ctx)
if err != nil {
return fmt.Errorf("настройки записи: %w", err)
}
items, err := a.store.ConfirmQueue(ctx, 100)
if err != nil {
return err
}
if len(items) == 0 {
return nil
}
// 1. Лиды для новых записей. Не получилось — запись ждёт следующей попытки и автоматически
// не отменяется: оператор её ещё не видел.
var ids []string
for i := range items {
it := &items[i]
if it.BitrixLeadID == "" {
id, err := a.bitrix.AddLead(ctx, leadDraft(*it, cfg))
if err != nil {
slog.Warn("booking confirm: лид не создан", "appointment", it.ID, "err", err)
_ = a.store.SetConfirmError(ctx, it.ID, "Битрикс: "+err.Error())
continue
}
if err := a.store.SetBitrixLead(ctx, it.ID, id); err != nil {
return err
}
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)
}
}
}
ids = append(ids, it.BitrixLeadID)
}
if len(ids) == 0 {
return nil
}
// 2. Стадии лидов. Без ответа Битрикса ничего не отменяем: вдруг оператор уже подтвердил.
sem, err := a.bitrix.LeadSemantics(ctx, ids)
if err != nil {
return err
}
for _, it := range items {
if it.BitrixLeadID == "" {
continue
}
switch decideConfirm(sem[it.BitrixLeadID], it.CreatedAt, it.Start, now, cfg) {
case confirmOK:
a.finishConfirm(ctx, it, "confirmed")
case confirmMissed:
a.finishConfirm(ctx, it, "missed")
case confirmCancel:
if a.cancelUnconfirmed(ctx, it) {
a.finishConfirm(ctx, it, "cancelled")
a.leadNote(ctx, it, "Сайт отменил запись в 1С: лид переведён в провальную стадию. Окно у врача снова свободно.")
}
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С и перевёл лид в «Клиент не записался». "+
"Если пациент всё же придёт, запишите его заново.")
}
}
}
return nil
}
// sendLeadOnly передаёт оператору заявки, которые не удалось записать в 1С (1С недоступна или
// отклонила запись). Подтверждать тут нечего — записи в 1С нет, оператор записывает вручную.
func (a *API) sendLeadOnly(ctx context.Context) error {
items, err := a.store.LeadOnlyQueue(ctx, 50)
if err != nil {
return err
}
for _, it := range items {
id, err := a.bitrix.AddLead(ctx, leadOnlyDraft(it))
if err != nil {
slog.Warn("booking confirm: лид по заявке не создан", "appointment", it.ID, "err", err)
_ = a.store.SetConfirmError(ctx, it.ID, "Битрикс: "+err.Error())
continue
}
if err := a.store.LeadOnlySent(ctx, it.ID, id); err != nil {
return err
}
slog.Info("booking confirm: заявка передана в Битрикс", "appointment", it.ID, "lead", id)
}
return nil
}
func (a *API) finishConfirm(ctx context.Context, it storage.ConfirmItem, state string) {
if err := a.store.FinishBookingConfirm(ctx, it.ID, state); err != nil {
slog.Error("booking confirm: состояние не сохранено", "appointment", it.ID, "state", state, "err", err)
return
}
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)
}
}
// leadDraft — лид для оператора: всё, что нужно для звонка, и что будет, если не подтвердить.
// Поля пациента экранируются: в COMMENTS Битрикс показывает HTML.
func leadDraft(it storage.ConfirmItem, cfg storage.BookingSettings) bitrix.LeadDraft {
when := it.Start.In(moscow).Format("02.01 15:04")
phone := it.Phone
if n := normalizeRuPhone(it.Phone); n != "" {
phone = "+" + n
}
lines := []string{
"Пациент записался онлайн, окно в 1С уже занято за ним.",
"Врач: " + html.EscapeString(it.DoctorName),
"Приём: " + formatSlotTime(it.Start),
"Филиал: " + html.EscapeString(branchOrDash(it.Branch)),
}
if it.ServiceName != "" {
lines = append(lines, "Услуга: "+html.EscapeString(it.ServiceName))
}
if it.OnecID != "" {
lines = append(lines, "№ записи в 1С: "+html.EscapeString(it.OnecID))
}
lines = append(lines, "",
"Позвоните пациенту. Подтвердил — переведите лид в «Клиент записался». "+
"Любая провальная стадия отменит запись в 1С.")
if d, ok := confirmDeadline(it.CreatedAt, it.Start, cfg); ok {
lines = append(lines, "Без подтверждения запись отменится автоматически "+d.In(moscow).Format("02.01 в 15:04")+".")
}
return bitrix.LeadDraft{
Title: "Онлайн-запись: " + it.DoctorName + " — " + when,
Name: it.Patient,
Phone: phone,
SourceID: bitrixSiteSourceID,
SourceDescr: "Онлайн-запись на сайте",
Comments: strings.Join(lines, "<br>"),
}
}
// leadOnlyDraft — лид для заявки без записи в 1С. Причину сбоя 1С не пишем: в ней внутренние адреса.
func leadOnlyDraft(it storage.ConfirmItem) bitrix.LeadDraft {
phone := it.Phone
if n := normalizeRuPhone(it.Phone); n != "" {
phone = "+" + n
}
reason := "Пациент хотел записаться онлайн, но запись в 1С не прошла: 1С была недоступна или отклонила запись."
if strings.HasPrefix(it.Error, errBookingLimit.Error()) {
reason = "Пациент хотел записаться онлайн, но сработала защита от ботов: слишком много неподтверждённых онлайн-записей. " +
"Если пациент настоящий — запишите его."
}
lines := []string{
reason,
"Окно за пациентом НЕ закреплено.",
"Врач: " + html.EscapeString(it.DoctorName),
"Выбранное время: " + formatSlotTime(it.Start),
"Филиал: " + html.EscapeString(branchOrDash(it.Branch)),
}
if it.ServiceName != "" {
lines = append(lines, "Услуга: "+html.EscapeString(it.ServiceName))
}
lines = append(lines, "", "Позвоните пациенту и запишите его в 1С вручную.")
return bitrix.LeadDraft{
Title: "Заявка на запись: " + it.DoctorName + " — " + it.Start.In(moscow).Format("02.01 15:04"),
Name: it.Patient,
Phone: phone,
SourceID: bitrixSiteSourceID,
SourceDescr: "Онлайн-запись на сайте (1С не записала)",
Comments: strings.Join(lines, "<br>"),
}
}
// ---------- Настройки в админке ----------
func (a *API) adminBookingSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
cfg, err := a.store.BookingSettings(r.Context())
if err != nil {
handleErr(w, err)
return
}
ok(w, map[string]any{
"settings": cfg,
"confirm_enabled": a.bookingConfirmEnabled(),
"bitrix_configured": a.bitrix.Configured(),
"smsc_configured": a.smscLogin != "",
}, nil)
}
func (a *API) adminUpdateBookingSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
var in storage.BookingSettings
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil {
fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
return
}
if in.AutoCancelHours < 0 || in.AutoCancelHours > 168 {
fail(w, http.StatusUnprocessableEntity, "validation", "Автоотмена — от 0 до 168 часов после записи (0 — не отменять)")
return
}
if in.CancelBeforeHours < 0 || in.CancelBeforeHours > 48 {
fail(w, http.StatusUnprocessableEntity, "validation", "Отмена до приёма — от 0 до 48 часов (0 — не отменять)")
return
}
for _, c := range []struct {
v, max int
msg string
}{
{in.MaxPerDoctorDay, 50, "Неподтверждённых к врачу в день — от 0 до 50 (0 — без лимита)"},
{in.MaxPerHour, 1000, "Онлайн-записей за час — от 0 до 1000 (0 — без лимита)"},
{in.MaxActivePerPhone, 20, "Будущих записей на номер — от 0 до 20 (0 — без лимита)"},
} {
if c.v < 0 || c.v > c.max {
fail(w, http.StatusUnprocessableEntity, "validation", c.msg)
return
}
}
if in.PhoneCheck && a.smscLogin == "" {
fail(w, http.StatusUnprocessableEntity, "validation", "Проверочный звонок недоступен: на сервере не настроен SMSC")
return
}
if err := a.store.SetBookingSettings(r.Context(), in); err != nil {
handleErr(w, err)
return
}
a.store.Audit(r.Context(), u.ID, "update", "booking_settings", 0, clientIP(r))
a.adminBookingSettings(w, r, u)
}