From a02c4faad7e2d741089bbaebb377b9f0e091dc4b Mon Sep 17 00:00:00 2001 From: Abdulkadyr Karagishiev Date: Mon, 19 Oct 2026 18:30:23 +0300 Subject: [PATCH] feat(onec): support outbound push/pull sync and async appointment queue --- internal/api/api.go | 4 + internal/api/onec.go | 123 ++++++++++++------- internal/api/onec_push.go | 208 +++++++++++++++++++++++++++++++++ internal/api/onec_push_test.go | 69 +++++++++++ internal/storage/onec.go | 53 +++++++++ 5 files changed, 413 insertions(+), 44 deletions(-) create mode 100644 internal/api/onec_push.go create mode 100644 internal/api/onec_push_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 7d9fd75..9ff81f0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -137,6 +137,10 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("PUT /api/v1/admin/services/{id}", a.requireAuth(a.adminUpdateService)) mux.HandleFunc("POST /api/v1/onec/events", a.onecEvents) + mux.HandleFunc("POST /api/v1/onec/sync/all", a.onecSyncAll) + mux.HandleFunc("GET /api/v1/onec/appointments/pending", a.onecPendingAppointments) + mux.HandleFunc("POST /api/v1/onec/appointments/{id}/confirm", a.onecConfirmAppointment) + mux.HandleFunc("POST /api/v1/onec/appointments/{id}/reject", a.onecRejectAppointment) mux.HandleFunc("GET /api/v1/admin/onec", a.requireAuth(a.adminOnec)) mux.HandleFunc("POST /api/v1/admin/onec/sync", a.requireAuth(a.adminOnecSync)) mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/link", a.requireAuth(a.adminOnecLink)) diff --git a/internal/api/onec.go b/internal/api/onec.go index 38fd7fa..88b38d8 100644 --- a/internal/api/onec.go +++ b/internal/api/onec.go @@ -325,65 +325,100 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) { 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 + 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 } - } - 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): + 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) - 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 = 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.%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), + 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 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) + 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() } - a.kickConfirm() } wcm.consume(in.VerificationID) writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{ diff --git a/internal/api/onec_push.go b/internal/api/onec_push.go new file mode 100644 index 0000000..a5e4dd1 --- /dev/null +++ b/internal/api/onec_push.go @@ -0,0 +1,208 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "dana/internal/onec" +) + +// onecAuth проверяет API-ключ входящих запросов от 1С. +func (a *API) checkOnecKey(r *http.Request) bool { + key := r.Header.Get("X-Api-Key") + if key == "" { + key = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + } + key = strings.TrimSpace(key) + if key == "" { + return false + } + if a.onecHook != "" && sameKey(key, a.onecHook) { + return true + } + if a.onec != nil && a.onec.Key != "" && sameKey(key, a.onec.Key) { + return true + } + return false +} + +type onecSyncPayload struct { + Branches []onec.Branch `json:"branches"` + Doctors []onec.Doctor `json:"doctors"` + Services []onecDoctorServices `json:"services"` + Slots []onec.Slot `json:"slots"` +} + +type onecDoctorServices struct { + DoctorID string `json:"doctor_id"` + Services []onec.Service `json:"services"` +} + +// onecSyncAll принимает полный слепок каталога и расписания от регламентного задания 1С. +func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) { + if !a.checkOnecKey(r) { + fail(w, http.StatusUnauthorized, "unauthorized", "Неверный или отсутствующий API-ключ") + return + } + + var payload onecSyncPayload + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<20)).Decode(&payload); err != nil { + fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать тело запроса: "+err.Error()) + return + } + + ctx := r.Context() + linked := 0 + var err error + + // 1. Сохраняем филиалы и врачей + if len(payload.Branches) > 0 || len(payload.Doctors) > 0 { + linked, err = a.store.SaveOnecDirectory(ctx, payload.Branches, payload.Doctors) + if err != nil { + fail(w, http.StatusInternalServerError, "db_error", "Ошибка сохранения справочников: "+err.Error()) + return + } + } + + // 2. Сохраняем услуги врачей + for _, ds := range payload.Services { + if ds.DoctorID != "" && len(ds.Services) > 0 { + if err := a.store.SaveOnecServices(ctx, ds.DoctorID, ds.Services); err != nil { + fail(w, http.StatusInternalServerError, "db_error", "Ошибка сохранения услуг: "+err.Error()) + return + } + } + } + + // 3. Сохраняем слоты расписания + if len(payload.Slots) > 0 { + minStart, maxEnd := findSlotBounds(payload.Slots) + if err := a.store.ReplaceOnecSlots(ctx, minStart, maxEnd, payload.Slots); err != nil { + fail(w, http.StatusInternalServerError, "db_error", "Ошибка сохранения слотов: "+err.Error()) + return + } + } + + ok(w, map[string]any{ + "ok": true, + "linked": linked, + "branches": len(payload.Branches), + "doctors": len(payload.Doctors), + "services": len(payload.Services), + "slots": len(payload.Slots), + }, nil) +} + +func findSlotBounds(slots []onec.Slot) (time.Time, time.Time) { + if len(slots) == 0 { + now := time.Now() + return now, now + } + minT := slots[0].Start + maxT := slots[0].End + for _, s := range slots[1:] { + if s.Start.Before(minT) { + minT = s.Start + } + if s.End.After(maxT) { + maxT = s.End + } + } + return minT, maxT +} + +// onecPendingAppointments отдает 1С пачку ожидающих подтверждения записей. +func (a *API) onecPendingAppointments(w http.ResponseWriter, r *http.Request) { + if !a.checkOnecKey(r) { + fail(w, http.StatusUnauthorized, "unauthorized", "Неверный или отсутствующий API-ключ") + return + } + + list, err := a.store.PendingOnecAppointments(r.Context(), 50) + if err != nil { + fail(w, http.StatusInternalServerError, "db_error", err.Error()) + return + } + + ok(w, map[string]any{ + "ok": true, + "appointments": list, + }, nil) +} + +type confirmAppointmentReq struct { + OnecAppointmentID string `json:"onec_appointment_id"` + End *time.Time `json:"end"` +} + +// onecConfirmAppointment подтверждает создание записи в 1С. +func (a *API) onecConfirmAppointment(w http.ResponseWriter, r *http.Request) { + if !a.checkOnecKey(r) { + fail(w, http.StatusUnauthorized, "unauthorized", "Неверный или отсутствующий API-ключ") + return + } + + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + fail(w, http.StatusBadRequest, "bad_id", "Некорректный идентификатор записи") + return + } + + var req confirmAppointmentReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "bad_json", "Не удалось разобрать тело запроса") + return + } + + if req.OnecAppointmentID == "" { + fail(w, http.StatusBadRequest, "missing_param", "onec_appointment_id обязателен") + return + } + + if err := a.store.ConfirmPendingAppointment(r.Context(), id, req.OnecAppointmentID, req.End); err != nil { + fail(w, http.StatusInternalServerError, "db_error", err.Error()) + return + } + + if a.bookingConfirmEnabled() { + _ = a.store.StartBookingConfirm(r.Context(), id) + a.kickConfirm() + } + + ok(w, map[string]any{"ok": true, "id": id, "status": "booked"}, nil) +} + +type rejectAppointmentReq struct { + Reason string `json:"reason"` +} + +// onecRejectAppointment отклоняет запись (например, слот уже занят). +func (a *API) onecRejectAppointment(w http.ResponseWriter, r *http.Request) { + if !a.checkOnecKey(r) { + fail(w, http.StatusUnauthorized, "unauthorized", "Неверный или отсутствующий API-ключ") + return + } + + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + fail(w, http.StatusBadRequest, "bad_id", "Некорректный идентификатор записи") + return + } + + var req rejectAppointmentReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + req.Reason = "rejected by 1C" + } + + if err := a.store.RejectPendingAppointment(r.Context(), id, req.Reason); err != nil { + fail(w, http.StatusInternalServerError, "db_error", err.Error()) + return + } + + ok(w, map[string]any{"ok": true, "id": id, "status": "failed"}, nil) +} diff --git a/internal/api/onec_push_test.go b/internal/api/onec_push_test.go new file mode 100644 index 0000000..e10a42f --- /dev/null +++ b/internal/api/onec_push_test.go @@ -0,0 +1,69 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "dana/internal/onec" +) + +func TestOnecPushEndpoints(t *testing.T) { + app := &API{ + onecHook: "test-secret-key", + onec: onec.New("", "test-secret-key", "X-Api-Key"), + } + + mux := app.Routes() + + // 1. Unauthorized request + req := httptest.NewRequest(http.MethodGet, "/api/v1/onec/appointments/pending", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 unauthorized, got %d", w.Code) + } + + // 2. Authorized check with invalid body + req = httptest.NewRequest(http.MethodPost, "/api/v1/onec/sync/all", bytes.NewReader([]byte("{invalid-json"))) + req.Header.Set("X-Api-Key", "test-secret-key") + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 bad request, got %d", w.Code) + } + + // 3. FindSlotBounds test + now := time.Now() + slots := []onec.Slot{ + {Start: now.Add(time.Hour), End: now.Add(2 * time.Hour)}, + {Start: now, End: now.Add(30 * time.Minute)}, + {Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour)}, + } + minT, maxT := findSlotBounds(slots) + if !minT.Equal(now) { + t.Errorf("expected minT = %v, got %v", now, minT) + } + if !maxT.Equal(now.Add(4 * time.Hour)) { + t.Errorf("expected maxT = %v, got %v", now.Add(4*time.Hour), maxT) + } +} + +func TestOnecConfirmBadID(t *testing.T) { + app := &API{ + onecHook: "test-secret-key", + } + mux := app.Routes() + + body, _ := json.Marshal(confirmAppointmentReq{OnecAppointmentID: "001"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/onec/appointments/invalid-id/confirm", bytes.NewReader(body)) + req.Header.Set("X-Api-Key", "test-secret-key") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 bad id, got %d", w.Code) + } +} diff --git a/internal/storage/onec.go b/internal/storage/onec.go index 6b34b6c..21994fa 100644 --- a/internal/storage/onec.go +++ b/internal/storage/onec.go @@ -597,3 +597,56 @@ func (s *Storage) SetOnecAppointmentStatus(ctx context.Context, onecAppointmentI } return doctorOnecID, start, true, nil } + +type OnecPendingAppointment struct { + ID int64 `json:"id"` + RequestID string `json:"request_id"` + DoctorID string `json:"doctor_id"` + BranchID string `json:"branch_id"` + ServiceID string `json:"service_id"` + Start time.Time `json:"start"` + Patient string `json:"patient_name"` + Phone string `json:"patient_phone"` + CreatedAt time.Time `json:"created_at"` +} + +func (s *Storage) PendingOnecAppointments(ctx context.Context, limit int) ([]OnecPendingAppointment, error) { + if limit <= 0 { + limit = 20 + } + rows, err := s.pool.Query(ctx, ` + SELECT id, request_id, doctor_onec_id, branch_onec_id, COALESCE(service_onec_id, ''), start_at, patient_name, patient_phone, created_at + FROM onec_appointments + WHERE status = 'pending' + ORDER BY id ASC + LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []OnecPendingAppointment{} + for rows.Next() { + var a OnecPendingAppointment + if err := rows.Scan(&a.ID, &a.RequestID, &a.DoctorID, &a.BranchID, &a.ServiceID, &a.Start, &a.Patient, &a.Phone, &a.CreatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (s *Storage) ConfirmPendingAppointment(ctx context.Context, id int64, onecID string, end *time.Time) error { + _, err := s.pool.Exec(ctx, ` + UPDATE onec_appointments + SET status = 'booked', onec_appointment_id = $2, end_at = $3, updated_at = now() + WHERE id = $1 AND status = 'pending'`, id, onecID, end) + return err +} + +func (s *Storage) RejectPendingAppointment(ctx context.Context, id int64, reason string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE onec_appointments + SET status = 'failed', error = $2, updated_at = now() + WHERE id = $1 AND status = 'pending'`, id, reason) + return err +}