From 30cdeb8dbc357ea7f253baa84e7110ff1b2c33b4 Mon Sep 17 00:00:00 2001 From: Dana Medical Developer Date: Wed, 21 Oct 2026 12:38:59 +0000 Subject: [PATCH] fix(onec): show all actual supplied schedule dates --- internal/api/onec.go | 12 ++-- internal/api/onec_all_dates_test.go | 95 +++++++++++++++++++++++++++++ internal/api/onec_push.go | 4 +- internal/storage/onec.go | 9 ++- 4 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 internal/api/onec_all_dates_test.go diff --git a/internal/api/onec.go b/internal/api/onec.go index 11d0b9d..e11abe7 100644 --- a/internal/api/onec.go +++ b/internal/api/onec.go @@ -62,8 +62,10 @@ func (a *API) doctorSlots(w http.ResponseWriter, r *http.Request) { off() return } - from, to := onecWindow() - slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, to) + from, _ := onecWindow() + // Показываем все реальные будущие окна из принятых снимков 1С. + // Нулевая верхняя граница не ограничивает даты в SQL. + slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, time.Time{}) if err != nil { handleErr(w, err) return @@ -139,7 +141,7 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) { 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)) { + if err != nil || start.Before(time.Now()) { fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз") return } @@ -269,8 +271,8 @@ func (a *API) repeatAppointment(w http.ResponseWriter, rec storage.OnecAppointme // 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) + from, _ := onecWindow() + slots, _ := a.store.FreeOnecSlots(r.Context(), onecID, from, time.Time{}) if slots == nil { slots = []storage.OnecSlot{} } diff --git a/internal/api/onec_all_dates_test.go b/internal/api/onec_all_dates_test.go new file mode 100644 index 0000000..34fd5fe --- /dev/null +++ b/internal/api/onec_all_dates_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "os" + "testing" + "time" + + "dana/internal/onec" + "dana/internal/storage" +) + +// Uses only a disposable database; no real 1C calls or patient records. +func TestAutonomousAllActualFutureDates(t *testing.T) { + dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN") + if dsn == "" { + t.Skip("requires disposable autonomy DB") + } + ctx := context.Background() + st, err := storage.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err = st.Migrate(ctx); err != nil { + t.Fatal(err) + } + app := &API{store: st, limiter: newRateLimiter(), onecHook: "all-dates-test"} + mux := app.Routes() + call := func(method, path string, body any, want int) *httptest.ResponseRecorder { + t.Helper() + b, _ := json.Marshal(body) + r := httptest.NewRequest(method, path, bytes.NewReader(b)) + r.Header.Set("X-Api-Key", "all-dates-test") + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != want { + t.Fatalf("%s %s: %d, want %d: %s", method, path, w.Code, want, w.Body.String()) + } + return w + } + doctor := fmt.Sprintf("all-dates-%d", time.Now().UnixNano()) + var doctorID int64 + if err = st.Pool().QueryRow(ctx, `INSERT INTO doctors(slug,last_name,first_name,booking_provider) VALUES($1,'Тест','Все даты','native') RETURNING id`, doctor).Scan(&doctorID); err != nil { + t.Fatal(err) + } + if _, err = st.Pool().Exec(ctx, `INSERT INTO onec_doctors(onec_id,full_name,doctor_id) VALUES($1,'Тест Все даты',$2)`, doctor, doctorID); err != nil { + t.Fatal(err) + } + if err = st.SetBookingSettings(ctx, storage.BookingSettings{}); err != nil { + t.Fatal(err) + } + from := time.Now().UTC().Truncate(time.Hour) + far := from.AddDate(0, 0, 90) + end := far.Add(30 * time.Minute) + to := end.Add(time.Hour) + slot := onec.Slot{DoctorID: doctor, BranchID: "branch", Start: far, End: end, Status: "free"} + payload := onecSyncPayload{From: &from, To: &to, Branches: []onec.Branch{}, Doctors: []onec.Doctor{}, Services: []onecDoctorServices{}, Slots: []onec.Slot{slot}} + call("POST", "/api/v1/onec/sync/all", payload, 200) + shown := call("GET", "/api/v1/doctors/"+doctor+"/slots", nil, 200) + var result struct { + Data struct { + Enabled bool `json:"enabled"` + Slots []storage.OnecSlot `json:"slots"` + } `json:"data"` + } + if err = json.Unmarshal(shown.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if !result.Data.Enabled || len(result.Data.Slots) != 1 || !result.Data.Slots[0].Start.Equal(far) { + t.Fatalf("actual distant slot hidden: %s", shown.Body.String()) + } + creation := appointmentInput{DoctorSlug: doctor, Start: far.Format(time.RFC3339), Name: "Test", Phone: "79990000001", Consent: true, RequestID: doctor} + made := call("POST", "/api/v1/appointments", creation, 201) + if !bytes.Contains(made.Body.Bytes(), []byte(`"mode":"pending"`)) { + t.Fatal("distant actual slot not queued") + } + // A push claiming free must not reopen the pending reservation. + call("POST", "/api/v1/onec/sync/all", payload, 200) + hidden := call("GET", "/api/v1/doctors/"+doctor+"/slots", nil, 200) + if err = json.Unmarshal(hidden.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result.Data.Slots) != 0 { + t.Fatal("reserved distant slot reopened") + } + // Removing the calendar cutoff must not permit invented dates. + creation.Start = far.Add(time.Hour).Format(time.RFC3339) + creation.RequestID = doctor + "-invented" + call("POST", "/api/v1/appointments", creation, 409) +} diff --git a/internal/api/onec_push.go b/internal/api/onec_push.go index bc5b399..ae9b427 100644 --- a/internal/api/onec_push.go +++ b/internal/api/onec_push.go @@ -72,7 +72,9 @@ func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) { } else if len(payload.Slots) > 0 { from, to = findSlotBounds(payload.Slots) } - if !from.Before(to) || to.Sub(from) > 32*24*time.Hour { + // Даты определяет фактический снимок 1С, а не произвольный горизонт. + // Авторизация, лимит тела и проверка каждого слота остаются обязательными. + if !from.Before(to) { fail(w, 400, "validation", "Некорректный диапазон снимка") return } diff --git a/internal/storage/onec.go b/internal/storage/onec.go index f322132..e0c6d49 100644 --- a/internal/storage/onec.go +++ b/internal/storage/onec.go @@ -253,14 +253,19 @@ func (s *Storage) OnecDoctorBySlug(ctx context.Context, slug string) (onecID str // FreeOnecSlots — свободные окна врача в будущем; названия филиалов берутся из справочника 1С. func (s *Storage) FreeOnecSlots(ctx context.Context, doctorOnecID string, from, to time.Time) ([]OnecSlot, error) { + var upper any + if !to.IsZero() { + upper = to + } rows, err := s.pool.Query(ctx, ` SELECT sl.start_at, sl.end_at, sl.branch_onec_id, COALESCE(NULLIF(b.address, ''), b.name, ''), sl.cabinet, sl.slot_id FROM onec_slots sl LEFT JOIN onec_branches b ON b.onec_id = sl.branch_onec_id - WHERE sl.doctor_onec_id=$1 AND sl.status='free' AND sl.start_at >= greatest($2::timestamptz, now()) AND sl.start_at < $3 AND NOT EXISTS ( + WHERE sl.doctor_onec_id=$1 AND sl.status='free' AND sl.start_at >= greatest($2::timestamptz, now()) + AND ($3::timestamptz IS NULL OR sl.start_at < $3) AND NOT EXISTS ( SELECT 1 FROM onec_appointments a WHERE a.doctor_onec_id=sl.doctor_onec_id AND a.status IN ('pending','booked','arrived') AND a.start_at < sl.end_at AND COALESCE(a.end_at,a.start_at+interval '30 minutes') > sl.start_at) - ORDER BY sl.start_at, sl.branch_onec_id`, doctorOnecID, from, to) + ORDER BY sl.start_at, sl.branch_onec_id`, doctorOnecID, from, upper) if err != nil { return nil, err }