dana-medical-site/internal/api/onec.go
2026-10-21 12:38:59 +00:00

451 lines
18 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"
"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
}
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()) {
fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз")
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)})
}