dana-medical-site/internal/api/onec_push.go

310 lines
9.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}