merge: integrate outbound push/pull 1c sync with web updates
Some checks failed
Deploy Dana Medical Site / deploy (push) Failing after 1s
Some checks failed
Deploy Dana Medical Site / deploy (push) Failing after 1s
This commit is contained in:
commit
33b0b89271
@ -9,6 +9,10 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Run Deploy Script
|
- name: Trigger Automated Deployment
|
||||||
run: |
|
run: |
|
||||||
echo "Deployment triggered by Gitea Actions push event."
|
echo "Starting automatic deployment for dana-medical..."
|
||||||
|
docker run --rm \
|
||||||
|
-v /home/mikail/dana-medical:/app \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
docker:cli sh -c "cd /app && ./deploy.sh"
|
||||||
|
|||||||
@ -137,6 +137,10 @@ func (a *API) Routes() http.Handler {
|
|||||||
mux.HandleFunc("PUT /api/v1/admin/services/{id}", a.requireAuth(a.adminUpdateService))
|
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/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("GET /api/v1/admin/onec", a.requireAuth(a.adminOnec))
|
||||||
mux.HandleFunc("POST /api/v1/admin/onec/sync", a.requireAuth(a.adminOnecSync))
|
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))
|
mux.HandleFunc("PUT /api/v1/admin/onec/doctors/{onec_id}/link", a.requireAuth(a.adminOnecLink))
|
||||||
|
|||||||
@ -325,65 +325,100 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
var slot *onec.Slot
|
||||||
for i := range fresh {
|
var end time.Time
|
||||||
s := fresh[i]
|
var apptID string
|
||||||
if s.Free() && s.Start.Equal(start) && (in.BranchID == "" || s.BranchID == in.BranchID) {
|
|
||||||
slot = &s
|
if a.onec.Configured() {
|
||||||
break
|
// Окно ещё свободно? Спрашиваем 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{
|
appt, err := a.onec.Book(ctx, onec.AppointmentRequest{
|
||||||
RequestID: in.RequestID, DoctorID: onecID, BranchID: slot.BranchID, ServiceID: in.ServiceID, SlotID: slot.SlotID,
|
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,
|
Start: slot.Start, Name: in.Name, Phone: in.Phone, BirthDate: in.BirthDate, Comment: in.Comment, Source: onecSourceLabel,
|
||||||
})
|
})
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, onec.ErrSlotBusy):
|
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.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
|
end = appt.End
|
||||||
if end.IsZero() {
|
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
|
end = slot.End
|
||||||
}
|
}
|
||||||
|
|
||||||
branch := a.store.OnecBranchName(ctx, slot.BranchID)
|
branch := a.store.OnecBranchName(ctx, slot.BranchID)
|
||||||
confirm := a.bookingConfirmEnabled()
|
confirm := a.bookingConfirmEnabled()
|
||||||
note := "Запись подтверждена в 1С"
|
note := "Запись подтверждена в 1С"
|
||||||
if confirm {
|
if confirm {
|
||||||
note = "Запись сделана в 1С, ждёт подтверждения оператором в Битрикс24"
|
note = "Запись сделана в 1С, ждёт подтверждения оператором в Битрикс24"
|
||||||
}
|
}
|
||||||
leadID := a.saveAppointmentLead(ctx, r, in, doctorID, fmt.Sprintf("%s (№ %s): %s, %s.%s",
|
leadID := a.saveAppointmentLead(ctx, r, in, doctorID, fmt.Sprintf("%s: %s, %s.%s",
|
||||||
note, appt.ID, formatSlotTime(slot.Start), branchOrDash(branch), a.serviceNote(ctx, onecID, in.ServiceID)), map[string]any{
|
note, 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),
|
"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 {
|
if apptID != "" {
|
||||||
slog.Error("onec appointment finish", "id", rec.ID, "err", err)
|
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 {
|
if confirm {
|
||||||
slog.Error("booking confirm start", "id", rec.ID, "err", err)
|
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)
|
wcm.consume(in.VerificationID)
|
||||||
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{
|
writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{
|
||||||
|
|||||||
208
internal/api/onec_push.go
Normal file
208
internal/api/onec_push.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
69
internal/api/onec_push_test.go
Normal file
69
internal/api/onec_push_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -597,3 +597,56 @@ func (s *Storage) SetOnecAppointmentStatus(ctx context.Context, onecAppointmentI
|
|||||||
}
|
}
|
||||||
return doctorOnecID, start, true, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user