package api import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "regexp" "strconv" "strings" "time" "unicode/utf8" "dana/internal/domain" "dana/internal/onec" "dana/internal/storage" ) // 1С сама отправляет расписание и забирает pending. Сайт не вызывает 1С по сети. const ( onecHorizonDays = 14 onecStaleAfter = 24 * time.Hour // после этого срока без удачной синхронизации окна не показываем maxAppointmentIn = 16 << 10 ) // WithOneC подключает 1С; без адреса или ключа запись через 1С выключена. func (a *API) WithOneC(c *onec.Client) { a.onec = c } func onecWindow() (from, to time.Time) { now := time.Now().In(moscow) from = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, moscow) return from, from.AddDate(0, 0, onecHorizonDays) } func onecErrText(err error) string { if errors.Is(err, onec.ErrUnauthorized) { return "1С не приняла ключ доступа: проверьте ONEC_API_KEY и что IP сервера сайта открыт в 1С. " + err.Error() } return err.Error() } // ---------- Публичное API ---------- // doctorSlots отдаёт свободные окна врача и услуги приёма. enabled=false — запись через 1С недоступна, // и фронт остаётся на обычной заявке (у врача не включена запись, 1С не подключена или давно не отвечала). func (a *API) doctorSlots(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") off := func() { ok(w, map[string]any{"enabled": false, "slots": []any{}, "services": []any{}}, nil) } if !a.onecAutonomousEnabled() { off() return } onecID, _, err := a.store.OnecDoctorBySlug(r.Context(), r.PathValue("slug")) if err != nil { handleErr(w, err) return } if onecID == "" || !a.store.OnecFresh(r.Context(), onecStaleAfter) { off() return } from, _ := onecWindow() // Показываем все реальные будущие окна из принятых снимков 1С. // Нулевая верхняя граница не ограничивает даты в SQL. slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, time.Time{}) if err != nil { handleErr(w, err) return } // The current import duplicates one mixed catalog across doctors of unrelated // specialties. Keep it in storage for reconciliation/history, but never present // it as a verified list of this doctor's procedures (including to old clients). ok(w, map[string]any{"enabled": true, "slots": slots, "services": []onec.Service{}}, map[string]any{"total": len(slots)}) } type appointmentInput struct { DoctorSlug string `json:"doctor_slug"` Start string `json:"start"` BranchID string `json:"branch_id"` ServiceID string `json:"service_id"` Name string `json:"name"` Phone string `json:"phone"` VerificationID string `json:"verification_id"` BirthDate string `json:"birth_date"` Comment string `json:"comment"` Consent bool `json:"consent"` RequestID string `json:"request_id"` PageURL string `json:"page_url"` UTM map[string]string `json:"utm"` } var ( requestIDRE = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) birthDateRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) ) // createAppointment записывает пациента в 1С и заводит заявку в «Заявках». Сбой 1С (кроме «окно занято») // не теряет обращение: заявка сохраняется обычной, администратор перезвонит. func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) { if a.tooMany(w, "appt:"+clientIP(r), 10, 10*time.Minute) { return } var in appointmentInput if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxAppointmentIn)).Decode(&in); err != nil { fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать запрос") return } in.Name = strings.TrimSpace(in.Name) in.Phone = strings.TrimSpace(in.Phone) in.Comment = strings.TrimSpace(in.Comment) for _, v := range []string{in.Name, in.Phone, in.Comment} { if strings.ContainsRune(v, utf8.RuneError) { fail(w, http.StatusBadRequest, "bad_encoding", "Текст пришёл в неверной кодировке — отправьте форму ещё раз") return } } if !in.Consent { fail(w, http.StatusBadRequest, "consent_required", "Нужно согласие на обработку персональных данных") return } if in.Name == "" { fail(w, http.StatusBadRequest, "name_required", "Укажите имя") return } if len(digitsRE.ReplaceAllString(in.Phone, "")) < 10 { fail(w, http.StatusBadRequest, "phone_invalid", "Укажите корректный телефон") return } if !requestIDRE.MatchString(in.RequestID) { fail(w, http.StatusBadRequest, "request_id_invalid", "Не удалось отправить запись, обновите страницу") return } if in.BirthDate != "" && !birthDateRE.MatchString(in.BirthDate) { fail(w, http.StatusBadRequest, "birth_invalid", "Дата рождения указана неверно") return } start, err := time.Parse(time.RFC3339, in.Start) if err != nil || start.Before(time.Now()) { fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз") return } // A previously loaded form must not attach a procedure from the unverified // catalog. Do not silently discard a choice the patient believes was recorded. if in.ServiceID != "" { fail(w, http.StatusUnprocessableEntity, "service_unavailable", "Список услуг обновлён. Закройте и снова откройте форму, чтобы записаться к врачу без выбора услуги.") return } if !a.onecAutonomousEnabled() { fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку") return } ctx := r.Context() if !a.store.OnecFresh(ctx, onecStaleAfter) { fail(w, 503, "onec_stale", "Расписание временно недоступно") return } onecID, doctorID, err := a.store.OnecDoctorBySlug(ctx, in.DoctorSlug) if err != nil { handleErr(w, err) return } if onecID == "" { fail(w, http.StatusNotFound, "doctor_not_bookable", "К этому врачу онлайн-запись недоступна, оставьте заявку") return } // Защита расписания от ботов. Обычно запись идёт без звонка, а при подозрительной активности // (всплеск записей, много неподтверждённых к врачу, много записей на номер) сайт просит // проверочный звонок: настоящий пациент записывается дальше, бот останавливается. // Без SMSC подозрительная запись не занимает окно, а уходит оператору заявкой. cfg, err := a.store.BookingSettings(ctx) if err != nil { slog.Warn("booking settings", "err", err) cfg = storage.DefaultBookingSettings } risk, err := a.bookingRiskReason(ctx, cfg, onecID, in.Phone, start) if err != nil { slog.Warn("booking limits", "err", err) } leadOnly := "" if cfg.PhoneCheck || risk != "" { if a.smscLogin != "" { if in.VerificationID == "" || !wcm.isVerified(in.VerificationID, in.Phone) { if risk != "" { slog.Warn("booking: прошу проверочный звонок", "reason", risk) } fail(w, http.StatusForbidden, "phone_not_verified", "Подтвердите номер телефона с помощью проверочного звонка") return } } else if risk != "" { leadOnly = risk } } // не больше 3 записей в час на один номер: проверяем после звонка, чтобы отклонённая попытка // без звонка не тратила пациенту лимит if a.tooMany(w, "appt-phone:"+lastDigits(in.Phone, 10), 3, time.Hour) { return } initialStatus := "pending" if leadOnly != "" { initialStatus = "failed" } rec, created, err := a.store.BeginOnecAppointment(ctx, storage.OnecAppointment{ RequestID: in.RequestID, DoctorOnec: onecID, BranchID: in.BranchID, ServiceID: in.ServiceID, Start: start, Patient: in.Name, Phone: in.Phone, Comment: in.Comment, BirthDate: in.BirthDate, Status: initialStatus, }) if errors.Is(err, storage.ErrOnecSlotBusy) { fail(w, http.StatusConflict, "slot_busy", "Окно уже занято; выберите другое время") return } if err != nil { handleErr(w, err) return } if !created { a.repeatAppointment(w, rec) return } if leadOnly != "" { slog.Warn("booking: SMSC не настроен, подозрительная запись станет заявкой", "reason", leadOnly) a.appointmentFallback(w, r, in, doctorID, rec, fmt.Errorf("%w: %s", errBookingLimit, leadOnly)) return } branch := a.store.OnecBranchName(ctx, rec.BranchID) leadID := a.saveAppointmentLead(ctx, r, in, doctorID, fmt.Sprintf("Ожидает регистрации в 1С: %s, %s.%s", formatSlotTime(rec.Start), branchOrDash(branch), a.serviceNote(ctx, onecID, in.ServiceID)), map[string]any{ "onec_doctor_id": onecID, "slot_start": rec.Start.Format(time.RFC3339), }) if err := a.store.AttachOnecLead(ctx, rec.ID, leadID); err != nil { slog.Error("attach pending appointment lead", "id", rec.ID, "err", err) } wcm.consume(in.VerificationID) writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{ "mode": "pending", "start": rec.Start, "end": rec.End, "branch": branch, "message": "Заявка принята. Подтверждение записи ожидается от клиники.", }}) } // bookedMessage — что видит пациент после записи. При подтверждении оператором честно говорим, что позвонят. func (a *API) bookedMessage(start time.Time) string { if a.bookingConfirmEnabled() { return "Вы записаны на приём " + formatSlotTime(start) + ". Администратор позвонит вам, чтобы подтвердить запись." } return "Вы записаны на приём " + formatSlotTime(start) + ". Ждём вас в клинике!" } // repeatAppointment отвечает на повтор того же запроса (тот же request_id), не создавая записи заново. func (a *API) repeatAppointment(w http.ResponseWriter, rec storage.OnecAppointment) { switch rec.Status { case "pending": writeJSON(w, http.StatusAccepted, envelope{Data: map[string]any{"mode": "pending", "start": rec.Start, "message": "Заявка принята. Ожидается подтверждение клиники."}}) case "booked": writeJSON(w, http.StatusOK, envelope{Data: map[string]any{ "mode": "booked", "start": rec.Start, "message": a.bookedMessage(rec.Start), }}) case "failed": writeJSON(w, http.StatusOK, envelope{Data: map[string]any{ "mode": "lead", "message": "Спасибо! Мы получили заявку и перезвоним, чтобы подтвердить запись.", }}) default: fail(w, http.StatusConflict, "in_progress", "Запись уже оформляется, подождите несколько секунд") } } // slotTaken — окно заняли: отдаём 409 и свежий список окон, чтобы пациент выбрал другое. func (a *API) slotTaken(w http.ResponseWriter, r *http.Request, rec storage.OnecAppointment, onecID, reason string) { _ = a.store.FinishOnecAppointment(context.Background(), rec.ID, "failed", "", "slot_busy: "+reason, nil, nil) from, _ := onecWindow() slots, _ := a.store.FreeOnecSlots(r.Context(), onecID, from, time.Time{}) if slots == nil { slots = []storage.OnecSlot{} } writeJSON(w, http.StatusConflict, map[string]any{ "error": map[string]any{"code": "slot_busy", "message": "Это время только что заняли. Выберите другое."}, "slots": slots, }) } // appointmentFallback — 1С не смогла принять запись: сохраняем обычную заявку и честно говорим об этом администратору. func (a *API) appointmentFallback(w http.ResponseWriter, r *http.Request, in appointmentInput, doctorID int64, rec storage.OnecAppointment, cause error) { slog.Warn("onec: запись не прошла, сохраняем заявку", "request_id", rec.RequestID, "err", cause) start, _ := time.Parse(time.RFC3339, in.Start) msg := fmt.Sprintf("НЕ ЗАПИСАН В 1С (%s). Пациент выбрал: %s. Перезвоните и запишите вручную.%s", onecErrText(cause), formatSlotTime(start), a.serviceNote(r.Context(), rec.DoctorOnec, in.ServiceID)) leadID := a.saveAppointmentLead(r.Context(), r, in, doctorID, msg, map[string]any{"onec_error": cause.Error()}) _ = a.store.FinishOnecAppointment(context.Background(), rec.ID, "failed", "", cause.Error(), nil, leadID) // оператор работает в Битрикс24 — передаём заявку туда, чтобы записать пациента вручную if a.bookingConfirmEnabled() { if err := a.store.MarkLeadOnly(context.Background(), rec.ID); err != nil { slog.Error("booking lead-only mark", "id", rec.ID, "err", err) } a.kickConfirm() } writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{ "mode": "lead", "message": "Спасибо! Мы получили заявку и перезвоним, чтобы подтвердить запись.", }}) } func (a *API) saveAppointmentLead(ctx context.Context, r *http.Request, in appointmentInput, doctorID int64, message string, extra map[string]any) *int64 { var formID *int64 if f, err := a.store.FormByCode(ctx, "appointment"); err == nil { formID = &f.ID } if in.Comment != "" { message += "\nКомментарий пациента: " + in.Comment } if in.BirthDate != "" { extra["birth_date"] = in.BirthDate } id, err := a.store.CreateLead(ctx, formID, domain.LeadInput{ Name: in.Name, Phone: in.Phone, DoctorID: &doctorID, Message: message, Extra: extra, PageURL: in.PageURL, UTM: cleanUTM(in.UTM), Consent: true, }, clientIP(r), r.UserAgent()) if err != nil { slog.Error("onec: заявка не сохранилась", "err", err) return nil } a.notifyLead(id) return &id } func (a *API) serviceNote(ctx context.Context, onecID, serviceID string) string { if serviceID == "" { return "" } if n := a.store.OnecServiceName(ctx, onecID, serviceID); n != "" { return " Услуга: " + n + "." } return "" } func formatSlotTime(t time.Time) string { return t.In(moscow).Format("02.01.2006 в 15:04") } func branchOrDash(s string) string { if s == "" { return "филиал не указан" } return s } // ---------- Админка ---------- func (a *API) adminOnec(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { st, err := a.store.OnecSyncStatus(r.Context()) if err != nil { handleErr(w, err) return } doctors, err := a.store.OnecDoctorsAdmin(r.Context()) if err != nil { handleErr(w, err) return } ok(w, map[string]any{ "configured": a.onecAutonomousEnabled(), "host": "Push/Pull", "status": st, "doctors": doctors, }, nil) } func (a *API) adminOnecSync(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { if err := a.store.RequestOnecSnapshot(r.Context()); err != nil { handleErr(w, err) return } a.adminOnec(w, r, u) } func (a *API) adminOnecLink(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { var in struct { DoctorID *int64 `json:"doctor_id"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") return } if err := a.store.SetOnecDoctorLink(r.Context(), r.PathValue("onec_id"), in.DoctorID); err != nil { handleErr(w, err) return } var id int64 if in.DoctorID != nil { id = *in.DoctorID } a.store.Audit(r.Context(), u.ID, "link", "onec_doctor", id, clientIP(r)) ok(w, map[string]any{"onec_id": r.PathValue("onec_id"), "doctor_id": in.DoctorID}, nil) } func (a *API) adminOnecBooking(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { var in struct { Enabled bool `json:"enabled"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос") return } if err := a.store.SetOnecBooking(r.Context(), r.PathValue("onec_id"), in.Enabled); err != nil { if errors.Is(err, storage.ErrNotFound) { fail(w, http.StatusUnprocessableEntity, "not_linked", "Сначала привяжите врача к карточке сайта") return } handleErr(w, err) return } a.store.Audit(r.Context(), u.ID, "booking", "onec_doctor", 0, clientIP(r)) ok(w, map[string]any{"onec_id": r.PathValue("onec_id"), "enabled": in.Enabled}, nil) } func (a *API) adminOnecGrid(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { day := time.Now().In(moscow) if s := r.URL.Query().Get("date"); s != "" { d, err := time.ParseInLocation("2006-01-02", s, moscow) if err != nil { fail(w, http.StatusBadRequest, "bad_date", "Дата указана неверно") return } day = d } day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, moscow) rows, err := a.store.OnecGrid(r.Context(), day) if err != nil { handleErr(w, err) return } ok(w, rows, map[string]any{"date": day.Format("2006-01-02")}) } func (a *API) adminOnecAppointments(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit <= 0 || limit > 500 { limit = 100 } list, err := a.store.OnecAppointments(r.Context(), limit) if err != nil { handleErr(w, err) return } // в списке — имена и телефоны пациентов: просмотр фиксируем в аудите a.store.Audit(r.Context(), u.ID, "view", "onec_appointments", 0, clientIP(r)) ok(w, list, map[string]any{"total": len(list)}) }