dana-medical-site/internal/api/booking_confirm.go
Abdulkadyr Karagishiev 121f286016 feat(crm): integrate website leads and online booking with new CRM and 1C
- Add internal/crm client with CanonicalJSON and HMAC-SHA256 signing for POST /api/public/leads
- Add database migration 0026_crm_deals.sql with crm_deal_id and partial indexes
- Add async lead intake dispatch to new CRM in handlers_leads.go
- Support dual-mode booking confirmation (CRM_MODE=both/crm_only/bitrix_only) in booking_confirm.go
- Add LinkCRM endpoint to bridge_1c.py and onec.Client
- Update config and env examples with CRM_API_URL, CRM_LEAD_INTAKE_SECRET, CRM_MODE
2026-10-16 18:01:49 +03:00

541 lines
23 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/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, "<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>"),
}
}
// 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)
}