package api import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "regexp" "strconv" "strings" "sync" "time" "unicode/utf8" "dana/internal/domain" "dana/internal/onec" "dana/internal/storage" ) // Расписание врачей и запись на приём из 1С: окна забираются по таймеру и хранятся у нас, // перед записью окно перепроверяется в 1С. Если 1С не отвечает, сайт не падает, а принимает обычную заявку. const ( onecHorizonDays = 14 onecSyncEvery = 5 * time.Minute onecDirEvery = time.Hour onecStaleAfter = 24 * time.Hour // после этого срока без удачной синхронизации окна не показываем onecSourceLabel = "Сайт" maxAppointmentIn = 16 << 10 ) // WithOneC подключает 1С; без адреса или ключа запись через 1С выключена. func (a *API) WithOneC(c *onec.Client) { a.onec = c } // WithOneCTuning задаёт период опроса (0 — по умолчанию) и включает long polling через GET /changes. func (a *API) WithOneCTuning(every time.Duration, longPoll bool) { a.onecEvery, a.onecLongPoll = every, longPoll } var onecSyncMu sync.Mutex // одна синхронизация за раз: фоновая и ручная не пересекаются 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) } // syncOneC забирает расписание на 14 дней; справочники (филиалы, врачи, услуги) — при dir=true. func (a *API) syncOneC(ctx context.Context, dir bool) (err error) { if !a.onec.Configured() { return errors.New("1С не подключена: задайте ONEC_API_URL и ONEC_API_KEY") } onecSyncMu.Lock() defer onecSyncMu.Unlock() logID, err := a.store.StartOnecSync(ctx) if err != nil { return err } doctors, slotsN := 0, 0 defer func() { errText := "" if err != nil { errText = onecErrText(err) } if e := a.store.FinishOnecSync(context.Background(), logID, doctors, slotsN, errText); e != nil { slog.Warn("onec sync log", "err", e) } }() if dir { branches, err := a.onec.Branches(ctx) if err != nil { return fmt.Errorf("филиалы: %w", err) } docs, err := a.onec.Doctors(ctx) if err != nil { return fmt.Errorf("врачи: %w", err) } linked, err := a.store.SaveOnecDirectory(ctx, branches, docs) if err != nil { return err } doctors = len(docs) if linked > 0 { slog.Info("onec: врачи привязаны по ФИО", "count", linked) } ids, err := a.store.LinkedOnecDoctorIDs(ctx) if err != nil { return err } for _, id := range ids { svc, err := a.onec.Services(ctx, id) if err != nil { slog.Warn("onec: услуги врача", "doctor", id, "err", err) continue } if err := a.store.SaveOnecServices(ctx, id, svc); err != nil { return err } } } from, to := onecWindow() slots, err := a.onec.Schedule(ctx, from, to, "") if err != nil { return fmt.Errorf("расписание: %w", err) } slotsN = len(slots) return a.store.ReplaceOnecSlots(ctx, from, to, slots) } func onecErrText(err error) string { if errors.Is(err, onec.ErrUnauthorized) { return "1С не приняла ключ доступа: проверьте ONEC_API_KEY и что IP сервера сайта открыт в 1С. " + err.Error() } return err.Error() } // RunOneCSync — фоновая синхронизация: при старте и каждые 5 минут; справочники — раз в час. func (a *API) RunOneCSync(ctx context.Context) { if !a.onec.Configured() { return } slog.Info("onec sync enabled", "host", a.onec.Host()) lastDir := time.Time{} run := func() { dir := time.Since(lastDir) >= onecDirEvery c, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() if err := a.syncOneC(c, dir); err != nil { slog.Warn("onec sync", "err", err) return } if dir { lastDir = time.Now() } } run() every := a.onecEvery if every <= 0 { every = onecSyncEvery } if a.onecLongPoll { go a.runOneCLongPoll(ctx) } t := time.NewTicker(every) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: run() } } } // ---------- Публичное 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.onec.Configured() { 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, to := onecWindow() slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, to) if err != nil { handleErr(w, err) return } services, err := a.store.OnecServices(r.Context(), onecID) if err != nil { handleErr(w, err) return } ok(w, map[string]any{"enabled": true, "slots": slots, "services": services}, 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()) || start.After(time.Now().AddDate(0, 0, onecHorizonDays+2)) { fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз") return } if !a.onec.Configured() { fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку") return } ctx := r.Context() 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 } 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, }) 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 } var slot *onec.Slot var end time.Time var apptID string if a.onec.Configured() { // Окно ещё свободно? Спрашиваем 1С по этому врачу на этот день. dayFrom := time.Date(start.In(moscow).Year(), start.In(moscow).Month(), start.In(moscow).Day(), 0, 0, 0, 0, moscow) dayTo := dayFrom.AddDate(0, 0, 1) fresh, err := a.onec.Schedule(ctx, dayFrom, dayTo, onecID) if err != nil { a.appointmentFallback(w, r, in, doctorID, rec, err) return } _ = a.store.ReplaceOnecDoctorSlots(ctx, onecID, dayFrom, dayTo, fresh) for i := range fresh { s := fresh[i] if s.Free() && s.Start.Equal(start) && (in.BranchID == "" || s.BranchID == in.BranchID) { slot = &s break } } if slot == nil { a.slotTaken(w, r, rec, onecID, "окно уже занято") return } appt, err := a.onec.Book(ctx, onec.AppointmentRequest{ RequestID: in.RequestID, DoctorID: onecID, BranchID: slot.BranchID, ServiceID: in.ServiceID, SlotID: slot.SlotID, Start: slot.Start, Name: in.Name, Phone: in.Phone, BirthDate: in.BirthDate, Comment: in.Comment, Source: onecSourceLabel, }) switch { case errors.Is(err, onec.ErrSlotBusy): _ = a.store.MarkOnecSlotBusy(ctx, onecID, slot.BranchID, slot.Start) a.slotTaken(w, r, rec, onecID, err.Error()) return case err != nil: a.appointmentFallback(w, r, in, doctorID, rec, err) return } _ = a.store.MarkOnecSlotBusy(ctx, onecID, slot.BranchID, slot.Start) end = appt.End if end.IsZero() { end = slot.End } apptID = appt.ID } else { // Режим Push/Pull (1С сама опрашивает очередь по регламентному заданию) localSlots, err := a.store.FreeOnecSlots(ctx, onecID, start, start.Add(time.Minute)) if err != nil || len(localSlots) == 0 { a.slotTaken(w, r, rec, onecID, "окно уже занято") return } for i := range localSlots { ls := localSlots[i] if in.BranchID == "" || ls.BranchID == in.BranchID { slot = &onec.Slot{ DoctorID: onecID, BranchID: ls.BranchID, Start: ls.Start, End: ls.End, Status: "free", } break } } if slot == nil { a.slotTaken(w, r, rec, onecID, "окно уже занято") return } _ = a.store.MarkOnecSlotBusy(ctx, onecID, slot.BranchID, slot.Start) end = slot.End } branch := a.store.OnecBranchName(ctx, slot.BranchID) confirm := a.bookingConfirmEnabled() note := "Запись подтверждена в 1С" if confirm { note = "Запись сделана в 1С, ждёт подтверждения оператором в Битрикс24" } leadID := a.saveAppointmentLead(ctx, r, in, doctorID, fmt.Sprintf("%s: %s, %s.%s", note, formatSlotTime(slot.Start), branchOrDash(branch), a.serviceNote(ctx, onecID, in.ServiceID)), map[string]any{ "onec_appointment_id": apptID, "onec_doctor_id": onecID, "slot_start": slot.Start.Format(time.RFC3339), }) if apptID != "" { if err := a.store.FinishOnecAppointment(context.Background(), rec.ID, "booked", apptID, "", &end, leadID); err != nil { slog.Error("onec appointment finish", "id", rec.ID, "err", err) } if confirm { if err := a.store.StartBookingConfirm(context.Background(), rec.ID); err != nil { slog.Error("booking confirm start", "id", rec.ID, "err", err) } a.kickConfirm() } } wcm.consume(in.VerificationID) writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{ "mode": "booked", "start": slot.Start, "end": end, "branch": branch, "message": a.bookedMessage(slot.Start), }}) } // 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 "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, to := onecWindow() slots, _ := a.store.FreeOnecSlots(r.Context(), onecID, from, to) 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.onec.Configured(), "host": a.onec.Host(), "status": st, "doctors": doctors, }, nil) } func (a *API) adminOnecSync(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() if err := a.syncOneC(ctx, true); err != nil { fail(w, http.StatusBadGateway, "onec_sync", onecErrText(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)}) } // runOneCLongPoll держит у 1С запрос /changes: как только версия расписания выросла, окна перечитываются сразу. func (a *API) runOneCLongPoll(ctx context.Context) { slog.Info("onec long polling enabled") var since int64 first := true for ctx.Err() == nil { wait := 25 * time.Second if first { wait = 0 // первый запрос только узнаёт текущую версию, не ожидая изменений } version, ids, err := a.onec.Changes(ctx, since, wait) if err != nil { slog.Warn("onec long poll", "err", err) select { case <-ctx.Done(): return case <-time.After(5 * time.Second): } continue } if first { first = false since = version // первый ответ только запоминает версию: полный опрос уже сделан при старте continue } if version <= since { continue } since = version if len(ids) == 0 { a.onecFullResyncAsync(false) continue } for _, id := range ids { if err := a.resyncOnecDoctor(ctx, id); err != nil { slog.Warn("onec long poll: врач не обновился", "doctor", id, "err", err) } } } }