package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"html"
"log/slog"
"net/http"
"strings"
"time"
"dana/internal/bitrix"
"dana/internal/crm"
"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.onecAutonomousEnabled() && (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 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
}
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 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.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)
}
}
}
}
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)
}
}
// 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:
a.queueAutonomousCancel(ctx, it, "cancelled")
case confirmExpire:
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) {
a.queueAutonomousCancel(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)
}
// 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)
}
}
// 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)
}