414 lines
18 KiB
Go
414 lines
18 KiB
Go
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)
|
||
}
|