455 lines
19 KiB
Go
455 lines
19 KiB
Go
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)})
|
||
}
|