310 lines
9.2 KiB
Go
310 lines
9.2 KiB
Go
package api
|
||
|
||
import (
|
||
"dana/internal/storage"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"regexp"
|
||
"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
|
||
}
|
||
|
||
var uuidTextRE = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||
|
||
type onecSyncPayload struct {
|
||
From *time.Time `json:"from"`
|
||
To *time.Time `json:"to"`
|
||
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
|
||
}
|
||
|
||
if payload.Branches == nil || payload.Doctors == nil || payload.Services == nil || payload.Slots == nil || (payload.From == nil) != (payload.To == nil) {
|
||
fail(w, 400, "validation", "Нужен полный снимок branches/doctors/services/slots и согласованный диапазон from/to")
|
||
return
|
||
}
|
||
payload.Slots = mergeOnecSlots(payload.Slots)
|
||
from, to := onecWindow()
|
||
if payload.From != nil && payload.To != nil {
|
||
from, to = *payload.From, *payload.To
|
||
} else if len(payload.Slots) > 0 {
|
||
from, to = findSlotBounds(payload.Slots)
|
||
}
|
||
if !from.Before(to) || to.Sub(from) > 32*24*time.Hour {
|
||
fail(w, 400, "validation", "Некорректный диапазон снимка")
|
||
return
|
||
}
|
||
for _, sl := range payload.Slots {
|
||
if sl.DoctorID == "" || !sl.Start.Before(sl.End) || sl.Start.Before(from) || !sl.Start.Before(to) || (sl.Status != "free" && sl.Status != "busy") {
|
||
fail(w, 400, "validation", "Некорректный слот")
|
||
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 != "" {
|
||
if err := a.store.SaveOnecServices(ctx, ds.DoctorID, ds.Services); err != nil {
|
||
fail(w, http.StatusInternalServerError, "db_error", "Ошибка сохранения услуг: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := a.store.AcceptOnecSlots(ctx, from, to, payload.Slots); err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
logID, err := a.store.StartOnecSync(ctx)
|
||
if err == nil {
|
||
_ = a.store.FinishOnecSync(ctx, logID, len(payload.Doctors), len(payload.Slots), "")
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
commands, err := a.store.PendingOnecCommands(r.Context())
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
requested, err := a.store.OnecSnapshotRequested(r.Context())
|
||
if err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
ok(w, map[string]any{
|
||
"commands": commands, "sync_requested": requested,
|
||
"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 !uuidTextRE.MatchString(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 {
|
||
if errors.Is(err, storage.ErrOnecTransition) {
|
||
fail(w, 409, "state_conflict", "Заявка уже обработана иначе")
|
||
return
|
||
}
|
||
handleErr(w, err)
|
||
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 {
|
||
if errors.Is(err, storage.ErrOnecTransition) {
|
||
fail(w, 409, "state_conflict", "Заявка уже обработана иначе")
|
||
return
|
||
}
|
||
fail(w, http.StatusInternalServerError, "db_error", err.Error())
|
||
return
|
||
}
|
||
|
||
if a.bookingConfirmEnabled() {
|
||
if err := a.store.MarkLeadOnly(r.Context(), id); err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
a.kickConfirm()
|
||
}
|
||
ok(w, map[string]any{"ok": true, "id": id, "status": "failed"}, nil)
|
||
}
|
||
|
||
func (a *API) onecCommandAck(w http.ResponseWriter, r *http.Request) {
|
||
if !a.checkOnecKey(r) {
|
||
fail(w, 401, "unauthorized", "Неверный ключ")
|
||
return
|
||
}
|
||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||
if err != nil || id <= 0 {
|
||
fail(w, 400, "bad_id", "Некорректная команда")
|
||
return
|
||
}
|
||
var body struct {
|
||
Success bool `json:"success"`
|
||
Error string `json:"error"`
|
||
}
|
||
if err = json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&body); err != nil {
|
||
fail(w, 400, "bad_json", "Некорректный JSON")
|
||
return
|
||
}
|
||
if err = a.store.AckOnecCommand(r.Context(), id, body.Success, body.Error); err != nil {
|
||
handleErr(w, err)
|
||
return
|
||
}
|
||
ok(w, map[string]any{"ok": true}, nil)
|
||
}
|
||
|
||
// Overlapping shifts can repeat the same key. A busy occurrence always wins.
|
||
func mergeOnecSlots(slots []onec.Slot) []onec.Slot {
|
||
out := make([]onec.Slot, 0, len(slots))
|
||
seen := map[string]int{}
|
||
for _, sl := range slots {
|
||
key := sl.DoctorID + "/" + sl.BranchID + "/" + sl.Start.UTC().Format(time.RFC3339Nano)
|
||
if i, ok := seen[key]; ok {
|
||
if sl.Status == "busy" {
|
||
out[i].Status = "busy"
|
||
}
|
||
if sl.End.After(out[i].End) {
|
||
out[i].End = sl.End
|
||
}
|
||
continue
|
||
}
|
||
seen[key] = len(out)
|
||
out = append(out, sl)
|
||
}
|
||
return out
|
||
}
|