645 lines
24 KiB
Go
645 lines
24 KiB
Go
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
|
||
}
|
||
|
||
// Окно ещё свободно? Спрашиваем 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)
|
||
var slot *onec.Slot
|
||
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
|
||
}
|
||
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.%s",
|
||
note, appt.ID, formatSlotTime(slot.Start), branchOrDash(branch), a.serviceNote(ctx, onecID, in.ServiceID)), map[string]any{
|
||
"onec_appointment_id": appt.ID, "onec_doctor_id": onecID, "slot_start": slot.Start.Format(time.RFC3339),
|
||
})
|
||
if err := a.store.FinishOnecAppointment(context.Background(), rec.ID, "booked", appt.ID, "", &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)
|
||
}
|
||
}
|
||
}
|
||
}
|