fix(onec): show all actual supplied schedule dates
This commit is contained in:
parent
7ac18ec61d
commit
30cdeb8dbc
@ -62,8 +62,10 @@ func (a *API) doctorSlots(w http.ResponseWriter, r *http.Request) {
|
|||||||
off()
|
off()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
from, to := onecWindow()
|
from, _ := onecWindow()
|
||||||
slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, to)
|
// Показываем все реальные будущие окна из принятых снимков 1С.
|
||||||
|
// Нулевая верхняя граница не ограничивает даты в SQL.
|
||||||
|
slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, time.Time{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleErr(w, err)
|
handleErr(w, err)
|
||||||
return
|
return
|
||||||
@ -139,7 +141,7 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
start, err := time.Parse(time.RFC3339, in.Start)
|
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", "Выберите время приёма ещё раз")
|
fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -269,8 +271,8 @@ func (a *API) repeatAppointment(w http.ResponseWriter, rec storage.OnecAppointme
|
|||||||
// slotTaken — окно заняли: отдаём 409 и свежий список окон, чтобы пациент выбрал другое.
|
// slotTaken — окно заняли: отдаём 409 и свежий список окон, чтобы пациент выбрал другое.
|
||||||
func (a *API) slotTaken(w http.ResponseWriter, r *http.Request, rec storage.OnecAppointment, onecID, reason string) {
|
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)
|
_ = a.store.FinishOnecAppointment(context.Background(), rec.ID, "failed", "", "slot_busy: "+reason, nil, nil)
|
||||||
from, to := onecWindow()
|
from, _ := onecWindow()
|
||||||
slots, _ := a.store.FreeOnecSlots(r.Context(), onecID, from, to)
|
slots, _ := a.store.FreeOnecSlots(r.Context(), onecID, from, time.Time{})
|
||||||
if slots == nil {
|
if slots == nil {
|
||||||
slots = []storage.OnecSlot{}
|
slots = []storage.OnecSlot{}
|
||||||
}
|
}
|
||||||
|
|||||||
95
internal/api/onec_all_dates_test.go
Normal file
95
internal/api/onec_all_dates_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -72,7 +72,9 @@ func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else if len(payload.Slots) > 0 {
|
} else if len(payload.Slots) > 0 {
|
||||||
from, to = findSlotBounds(payload.Slots)
|
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", "Некорректный диапазон снимка")
|
fail(w, 400, "validation", "Некорректный диапазон снимка")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -253,14 +253,19 @@ func (s *Storage) OnecDoctorBySlug(ctx context.Context, slug string) (onecID str
|
|||||||
|
|
||||||
// FreeOnecSlots — свободные окна врача в будущем; названия филиалов берутся из справочника 1С.
|
// FreeOnecSlots — свободные окна врача в будущем; названия филиалов берутся из справочника 1С.
|
||||||
func (s *Storage) FreeOnecSlots(ctx context.Context, doctorOnecID string, from, to time.Time) ([]OnecSlot, error) {
|
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, `
|
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
|
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
|
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
|
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 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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user