package api import ( "context" "encoding/json" "errors" "fmt" "html" "log/slog" "net/http" "strings" "time" "dana/internal/bitrix" "dana/internal/crm" "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.onec != nil && a.onec.Configured() && (a.bitrixEnabled() || a.crmEnabled()) } // 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 или CRM") return } slog.Info("booking confirm enabled: онлайн-записи передаются в CRM/Битрикс24", "mode", a.crmMode) 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 bitrixIds []string for i := range items { it := &items[i] if a.bitrixEnabled() && 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()) } else { 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) } } } } if a.crmEnabled() && it.CrmDealID == "" { dealID, err := a.crm.CreateLead(ctx, bookingConfirmToCRMDraft(*it, cfg), "") if err != nil { slog.Warn("booking confirm: сделка не создана в CRM", "appointment", it.ID, "err", err) _ = a.store.SetCrmError(ctx, it.ID, "CRM: "+err.Error()) } else { if err := a.store.SetCrmDeal(ctx, it.ID, dealID); err != nil { return err } it.CrmDealID = dealID slog.Info("booking confirm: сделка создана в CRM", "appointment", it.ID, "deal", dealID) if it.OnecID != "" { if err := a.onec.LinkCRM(ctx, it.OnecID, dealID); err != nil { slog.Warn("booking confirm: не удалось связать 1С со сделкой CRM", "onec_id", it.OnecID, "deal", dealID, "err", err) } else { slog.Info("booking confirm: заявка в 1С связана со сделкой CRM", "onec_id", it.OnecID, "deal", dealID) } } } } if it.BitrixLeadID != "" { bitrixIds = append(bitrixIds, it.BitrixLeadID) } } // 2. Стадии лидов в Битриксе (если Битрикс включен). if a.bitrixEnabled() && len(bitrixIds) > 0 { sem, err := a.bitrix.LeadSemantics(ctx, bitrixIds) 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С и перевёл лид в «Клиент не записался». "+ "Если пациент всё же придёт, запишите его заново.") } } } } 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") } } } } 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 i := range items { it := &items[i] if a.bitrixEnabled() && it.BitrixLeadID == "" { 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()) } else { 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 a.crmEnabled() && it.CrmDealID == "" { dealID, err := a.crm.CreateLead(ctx, leadOnlyToCRMDraft(*it), "") if err != nil { slog.Warn("booking confirm: заявка не создана в CRM", "appointment", it.ID, "err", err) _ = a.store.SetCrmError(ctx, it.ID, "CRM: "+err.Error()) } else { if err := a.store.LeadOnlyCrmSent(ctx, it.ID, dealID); err != nil { return err } it.CrmDealID = dealID slog.Info("booking confirm: заявка передана в CRM", "appointment", it.ID, "deal", dealID) } } bitrixDone := !a.bitrixEnabled() || it.BitrixLeadID != "" crmDone := !a.crmEnabled() || it.CrmDealID != "" if bitrixDone && crmDone { if err := a.store.FinishLeadOnly(ctx, it.ID); err != nil { slog.Warn("booking confirm: не удалось перевести в lead_sent", "appointment", it.ID, "err", err) } } } 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, "
"), } } // 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, "
"), } } // bookingConfirmToCRMDraft формирует черновик сделки для новой CRM при успешной записи в 1С. func bookingConfirmToCRMDraft(it storage.ConfirmItem, cfg storage.BookingSettings) crm.LeadDraft { phone := it.Phone if n := normalizeRuPhone(it.Phone); n != "" { phone = "+" + n } lines := []string{ "Онлайн-запись с сайта, окно в 1С занято за пациентом.", "Врач: " + it.DoctorName, "Приём: " + formatSlotTime(it.Start), "Филиал: " + branchOrDash(it.Branch), } if it.ServiceName != "" { lines = append(lines, "Услуга: "+it.ServiceName) } if it.OnecID != "" { lines = append(lines, "№ записи в 1С: "+it.OnecID) } if d, ok := confirmDeadline(it.CreatedAt, it.Start, cfg); ok { lines = append(lines, "Срок подтверждения: до "+d.In(moscow).Format("02.01 в 15:04")+".") } return crm.LeadDraft{ LocalLeadId: fmt.Sprintf("booking_%d", it.ID), Name: it.Patient, Phone: phone, Comment: strings.Join(lines, "\n"), SourceKey: "site_booking", SourceLabel: "Онлайн-запись на сайте", AttributionTimestamp: &it.CreatedAt, } } // leadOnlyToCRMDraft формирует черновик сделки для новой CRM при сбое записи в 1С. func leadOnlyToCRMDraft(it storage.ConfirmItem) crm.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, "Окно за пациентом НЕ закреплено.", "Врач: " + it.DoctorName, "Выбранное время: " + formatSlotTime(it.Start), "Филиал: " + branchOrDash(it.Branch), } if it.ServiceName != "" { lines = append(lines, "Услуга: "+it.ServiceName) } lines = append(lines, "", "Позвоните пациенту и запишите его в 1С вручную.") return crm.LeadDraft{ LocalLeadId: fmt.Sprintf("booking_failed_%d", it.ID), Name: it.Patient, Phone: phone, Comment: strings.Join(lines, "\n"), SourceKey: "site_booking_fallback", SourceLabel: "Онлайн-запись на сайте (1С не записала)", AttributionTimestamp: &it.CreatedAt, } } // ---------- Настройки в админке ---------- 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 != nil && a.bitrix.Configured(), "crm_configured": a.crm != nil && a.crm.IsConfigured(), "crm_mode": a.crmMode, "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) }