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) } // Даты определяет фактический снимок 1С, а не произвольный горизонт. // Авторизация, лимит тела и проверка каждого слота остаются обязательными. if !from.Before(to) { 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 }