dana-medical-site/internal/api/onec.go
Abdulkadyr Karagishiev a02c4faad7
Some checks failed
Deploy Dana Medical Site / deploy (push) Failing after 2s
feat(onec): support outbound push/pull sync and async appointment queue
2026-10-19 18:30:23 +03:00

680 lines
25 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"
"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)
}
}
}
}