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

533 lines
22 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/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, "<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)
}